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 })}`)
|
||||
},
|
||||
}
|
||||
@@ -35,6 +35,7 @@ const DETAIL_PREFERRED_CODES = new Set([
|
||||
"TRANSFER_EXCEEDS_AVAILABLE",
|
||||
"LEFTOVER_EXCEEDS_CONSUMED",
|
||||
"REQUIRED_FIELD_MISSING",
|
||||
"DUPLICATE_VENDOR_INVOICE_NO",
|
||||
])
|
||||
|
||||
const CODE_MESSAGES: Record<string, string> = {
|
||||
@@ -44,6 +45,11 @@ const CODE_MESSAGES: Record<string, string> = {
|
||||
ONHOLD_NOT_ISSUABLE: "This stock is on hold/quarantined and cannot be issued.",
|
||||
REASON_CODE_REQUIRED: "A reason code is required for this action.",
|
||||
PO_NOT_EDITABLE: "This purchase order can no longer be edited.",
|
||||
GRN_ALREADY_INVOICED: "This GRN has already been added to another purchase invoice.",
|
||||
GRN_VENDOR_MISMATCH: "All GRNs on a purchase invoice must be from the same vendor.",
|
||||
GRN_NOT_CONFIRMED: "Only confirmed GRNs can be added to a purchase invoice.",
|
||||
PURCHASE_INVOICE_NOT_EDITABLE: "This purchase invoice can no longer be edited.",
|
||||
DUPLICATE_VENDOR_INVOICE_NO: "This vendor invoice number has already been recorded for this vendor.",
|
||||
SKU_DUPLICATE: "That SKU already exists.",
|
||||
MASTER_IN_USE: "This record is referenced by other data and cannot be deleted.",
|
||||
CONCURRENCY_CONFLICT: "This record was changed by someone else. Reload and try again.",
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
// Client-side UX validation only — same thin convention as lib/validations/sales.ts.
|
||||
|
||||
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
|
||||
export function validateCompanyProfile(input: {
|
||||
legalName: string
|
||||
addressLine1: string
|
||||
city: string
|
||||
country: string
|
||||
phone: string
|
||||
email: string
|
||||
}): Record<string, string> {
|
||||
const errors: Record<string, string> = {}
|
||||
if (!input.legalName.trim()) errors.legalName = "Enter the company's legal name"
|
||||
if (!input.addressLine1.trim()) errors.addressLine1 = "Enter an address"
|
||||
if (!input.city.trim()) errors.city = "Enter a city"
|
||||
if (!input.country.trim()) errors.country = "Enter a country"
|
||||
if (!input.phone.trim()) errors.phone = "Enter a phone number"
|
||||
if (!input.email.trim()) errors.email = "Enter an email address"
|
||||
else if (!EMAIL_RE.test(input.email.trim())) errors.email = "Enter a valid email address"
|
||||
return errors
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
// Client-side UX validation only — required fields, format/range checks the browser can
|
||||
// already see. Server-authoritative rules (GRN eligibility, vendor match, duplicate vendor
|
||||
// invoice numbers) are never re-implemented here. Same pattern as lib/validations/sales.ts.
|
||||
|
||||
export function validatePurchaseInvoiceHeader(input: {
|
||||
vendorId: number | null
|
||||
vendorInvoiceNo: string
|
||||
vendorInvoiceDate: string
|
||||
grnIds: number[]
|
||||
}): Record<string, string> {
|
||||
const errors: Record<string, string> = {}
|
||||
if (!input.vendorId) errors.vendorId = "Select a vendor"
|
||||
if (!input.vendorInvoiceNo.trim()) errors.vendorInvoiceNo = "Enter the vendor's invoice number"
|
||||
if (!input.vendorInvoiceDate) {
|
||||
errors.vendorInvoiceDate = "Enter the vendor's invoice date"
|
||||
} else if (input.vendorInvoiceDate > new Date().toISOString().slice(0, 10)) {
|
||||
errors.vendorInvoiceDate = "Invoice date cannot be in the future"
|
||||
}
|
||||
if (input.grnIds.length === 0) errors.grnIds = "Select at least one GRN to consolidate"
|
||||
return errors
|
||||
}
|
||||
Reference in New Issue
Block a user