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,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()
|
||||
}
|
||||
@@ -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 })}`)
|
||||
},
|
||||
}
|
||||
Reference in New Issue
Block a user