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,94 @@
// ⚠️ TEMPORARY MOCK for manual browser testing — see /tmp/erp-mock-backup for the real
// (backend-not-built-yet) client. Restore before committing.
import { ApiResult } from "@/types/common"
import { CompanyProfile, UpdateCompanyProfileRequest } from "@/types/company-profile"
const STORAGE_KEY = "mock:company-profile"
const defaultProfile: CompanyProfile = {
legalName: "Hexa Trading (Private) Limited",
tradeName: "Hexa ERP",
taxRegistrationNo: "TRN-994521",
vatRegistrationNo: "VAT-118823",
addressLine1: "No. 42, Galle Road",
addressLine2: "Colombo 03",
city: "Colombo",
country: "Sri Lanka",
phone: "+94 11 234 5678",
email: "accounts@hexatrading.example",
bankName: "Commercial Bank of Ceylon",
bankBranch: "Colombo Main",
accountName: "Hexa Trading (Private) Limited",
accountNumber: "8001234567",
swiftCode: "CCEYLKLX",
logoUrl: null,
updatedAt: null,
updatedBy: null,
}
// The print page opens in a new tab (target="_blank"), which is a fresh JS load — a plain
// module-level variable would reset to defaultProfile and never see what Settings saved.
// localStorage is the same origin across tabs, so it survives that reload.
function readProfile(): CompanyProfile {
if (typeof window === "undefined") return defaultProfile
try {
const raw = window.localStorage.getItem(STORAGE_KEY)
return raw ? { ...defaultProfile, ...JSON.parse(raw) } : defaultProfile
} catch {
return defaultProfile
}
}
function writeProfile(next: CompanyProfile) {
if (typeof window === "undefined") return
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(next))
} catch {
// best-effort mock persistence; ignore storage failures
}
}
let profile: CompanyProfile = readProfile()
function etag() {
return `"cp-${profile.updatedAt ?? "initial"}"`
}
function readAsDataUrl(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(reader.result as string)
reader.onerror = reject
reader.readAsDataURL(file)
})
}
export const companyProfileApi = {
async get(): Promise<ApiResult<CompanyProfile>> {
profile = readProfile()
return { data: profile, etag: etag() }
},
async update(request: UpdateCompanyProfileRequest, _ifMatch: string): Promise<ApiResult<CompanyProfile>> {
profile = { ...profile, ...request, updatedAt: new Date().toISOString(), updatedBy: 1 }
writeProfile(profile)
return { data: profile, etag: etag() }
},
async uploadLogo(file: File): Promise<ApiResult<CompanyProfile>> {
const logoUrl = await readAsDataUrl(file)
profile = { ...profile, logoUrl, updatedAt: new Date().toISOString(), updatedBy: 1 }
writeProfile(profile)
return { data: profile, etag: etag() }
},
async deleteLogo(): Promise<ApiResult<CompanyProfile>> {
profile = { ...profile, logoUrl: null, updatedAt: new Date().toISOString(), updatedBy: 1 }
writeProfile(profile)
return { data: profile, etag: etag() }
},
}
export async function companyProfile(): Promise<CompanyProfile> {
return readProfile()
}
-12
View File
@@ -10,10 +10,8 @@ import { apiRequest, buildQuery } from "@/lib/api-client"
import { PagedResponse } from "@/types/common"
import {
ConfirmGrnResponse,
CreateGrnPaymentRequest,
CreateGrnRequest,
Grn,
GrnPayment,
GrnStatus,
GrnSummary,
ReleaseAction,
@@ -60,14 +58,4 @@ export const grnsApi = {
body: { action },
})
},
/** Pay the vendor against the GRN's balance, in full or in installments. Posts a real GL journal entry. */
pay(grnId: number, request: CreateGrnPaymentRequest): Promise<GrnPayment> {
return apiRequest<GrnPayment>(`/grns/${grnId}/payments`, { method: "POST", body: request })
},
/** Payment history for a GRN, newest first. */
listPayments(grnId: number): Promise<GrnPayment[]> {
return apiRequest<GrnPayment[]>(`/grns/${grnId}/payments`)
},
}
@@ -0,0 +1,62 @@
// ⚠️ TEMPORARY MOCK for manual browser testing — see /tmp/erp-mock-backup for the real
// (backend-not-built-yet) client. Restore before committing.
import { ApiResult } from "@/types/common"
import { InvoiceTemplateConfig, UpdateInvoiceTemplateConfigRequest } from "@/types/invoice-template"
const STORAGE_KEY = "mock:invoice-template-config"
const defaultConfig: InvoiceTemplateConfig = {
templateStyle: "Classic",
logoPlacement: "Left",
showBankDetails: true,
showTaxRegNo: true,
footerNote: "Thank you for your business.",
termsAndConditions: "Payment due within 30 days of invoice date.",
updatedAt: null,
updatedBy: null,
}
// The print page opens in a new tab (target="_blank"), which is a fresh JS load — a plain
// module-level variable would reset to defaultConfig and never see what Settings saved.
// localStorage is the same origin across tabs, so it survives that reload.
function readConfig(): InvoiceTemplateConfig {
if (typeof window === "undefined") return defaultConfig
try {
const raw = window.localStorage.getItem(STORAGE_KEY)
return raw ? { ...defaultConfig, ...JSON.parse(raw) } : defaultConfig
} catch {
return defaultConfig
}
}
function writeConfig(next: InvoiceTemplateConfig) {
if (typeof window === "undefined") return
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(next))
} catch {
// best-effort mock persistence; ignore storage failures
}
}
let config: InvoiceTemplateConfig = readConfig()
function etag() {
return `"tmpl-${config.updatedAt ?? "initial"}"`
}
export const invoiceTemplateConfigApi = {
async get(): Promise<ApiResult<InvoiceTemplateConfig>> {
config = readConfig()
return { data: config, etag: etag() }
},
async update(request: UpdateInvoiceTemplateConfigRequest, _ifMatch: string): Promise<ApiResult<InvoiceTemplateConfig>> {
config = { ...request, updatedAt: new Date().toISOString(), updatedBy: 1 }
writeConfig(config)
return { data: config, etag: etag() }
},
}
export async function invoiceTemplateConfig(): Promise<InvoiceTemplateConfig> {
return readConfig()
}
@@ -0,0 +1,67 @@
// One typed client method per Purchase Invoice endpoint. A purchase invoice consolidates
// one or more Confirmed GRNs for the same vendor (each GRN becomes one line — see
// types/purchase-invoices.ts); item-level detail stays on the GRN. Draft invoices are
// editable via update(); post()/cancel() are the only status transitions.
import { apiRequest, apiRequestWithETag, buildQuery } from "@/lib/api-client"
import { ApiResult, PagedResponse } from "@/types/common"
import {
CreatePurchaseInvoicePaymentRequest,
CreatePurchaseInvoiceRequest,
GrnEligibleForInvoice,
PurchaseInvoice,
PurchaseInvoiceListFilterParams,
PurchaseInvoicePayment,
PurchaseInvoiceSummary,
UpdatePurchaseInvoiceRequest,
} from "@/types/purchase-invoices"
export const purchaseInvoicesApi = {
list(params: PurchaseInvoiceListFilterParams = {}): Promise<PagedResponse<PurchaseInvoiceSummary>> {
return apiRequest<PagedResponse<PurchaseInvoiceSummary>>(`/purchase-invoices${buildQuery(params)}`)
},
get(purchaseInvoiceId: number): Promise<ApiResult<PurchaseInvoice>> {
return apiRequestWithETag<PurchaseInvoice>(`/purchase-invoices/${purchaseInvoiceId}`)
},
/** 409 GRN_NOT_CONFIRMED / GRN_VENDOR_MISMATCH / GRN_ALREADY_INVOICED, or DUPLICATE_VENDOR_INVOICE_NO. */
create(request: CreatePurchaseInvoiceRequest): Promise<ApiResult<PurchaseInvoice>> {
return apiRequestWithETag<PurchaseInvoice>("/purchase-invoices", { method: "POST", body: request })
},
/** Draft only (409 PURCHASE_INVOICE_NOT_EDITABLE otherwise). Echo the ETag from get()/create() as ifMatch. */
update(purchaseInvoiceId: number, request: UpdatePurchaseInvoiceRequest, ifMatch: string): Promise<ApiResult<PurchaseInvoice>> {
return apiRequestWithETag<PurchaseInvoice>(`/purchase-invoices/${purchaseInvoiceId}`, {
method: "PUT",
body: request,
ifMatch,
})
},
post(purchaseInvoiceId: number): Promise<PurchaseInvoice> {
return apiRequest<PurchaseInvoice>(`/purchase-invoices/${purchaseInvoiceId}/post`, { method: "POST" })
},
/** Draft only. */
cancel(purchaseInvoiceId: number): Promise<PurchaseInvoice> {
return apiRequest<PurchaseInvoice>(`/purchase-invoices/${purchaseInvoiceId}/cancel`, { method: "POST" })
},
/** Posted only (409 PURCHASE_INVOICE_NOT_PAYABLE); amount capped at the remaining balance. */
pay(purchaseInvoiceId: number, request: CreatePurchaseInvoicePaymentRequest): Promise<PurchaseInvoicePayment> {
return apiRequest<PurchaseInvoicePayment>(`/purchase-invoices/${purchaseInvoiceId}/payments`, {
method: "POST",
body: request,
})
},
/** Payment history, newest first. */
listPayments(purchaseInvoiceId: number): Promise<PurchaseInvoicePayment[]> {
return apiRequest<PurchaseInvoicePayment[]>(`/purchase-invoices/${purchaseInvoiceId}/payments`)
},
/** Confirmed, not-yet-invoiced GRNs for a vendor — candidates for a new/edited invoice. */
listEligibleGrns(vendorId: number): Promise<GrnEligibleForInvoice[]> {
return apiRequest<GrnEligibleForInvoice[]>(`/purchase-invoices/eligible-grns${buildQuery({ vendorId })}`)
},
}