Adds an "Email to vendor" button on the PO detail (available once approved,
through CLOSED, and again after payment) that opens an Outlook draft addressed
to the vendor's primary contact with a time-limited PDF download link.
Since mailto: can't attach files, the PDF is rendered and stored, and the draft
carries a link (the approach chosen for this issue):
- PdfService/: new standalone Express + Playwright microservice (GstService/
EpfoService pattern) — POST /pdf { url } renders a page to a real PDF via
headless Chromium. SSRF-guarded (shared token + optional origin allowlist).
- export route: accepts a server-only `svc` token (PDF_SERVICE_TOKEN) so
PdfService can fetch /api/po/[id]/export?format=pdf without a user session;
`pdf=1` drops the print button + window.print() auto-trigger.
- lib/pdf-service.ts renderPoPdf(); prepareVendorEmail() server action renders →
uploads to R2 (po-pdf/…) → presigns a 7-day link → returns a mailto draft.
- po-detail: EmailVendorButton, shown when approved + vendor has a contact email.
- Gated by PDF_SERVICE_URL/PDF_SERVICE_TOKEN; friendly error if unconfigured.
- No DB model/migration. Tests: prepareVendorEmail (6, PdfService/storage mocked).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
907 lines
44 KiB
TypeScript
907 lines
44 KiB
TypeScript
import { auth } from "@/auth";
|
|
import { db } from "@/lib/db";
|
|
import { NextRequest, NextResponse } from "next/server";
|
|
import ExcelJS from "exceljs";
|
|
import { TC_FIXED_LINE, TC_DEFAULTS } from "@/lib/validations/po";
|
|
import { downloadBuffer } from "@/lib/storage";
|
|
import { CANCELLED_WATERMARK_PNG_BASE64, CANCELLED_WATERMARK_W, CANCELLED_WATERMARK_H } from "@/lib/cancelled-watermark";
|
|
import { getImageSize, scaleToBox } from "@/lib/image-size";
|
|
import { signatoryLayout } from "@/lib/po-export-layout";
|
|
|
|
// ── Company fallback constants (used when no company is linked to a PO) ──────
|
|
|
|
const DEFAULT_CO_NAME = "PELAGIA MARINE SERVICES PVT. LTD";
|
|
const DEFAULT_CO_ADDR = "Office address: 409-410, ZION, Plot 273, Sector-10, Kharghar, Navi Mumbai- 410210";
|
|
const DEFAULT_CO_TEL = "Tel: +91-22-6909 9028 / Email: technical@pelagiamarine.com / Mob: +91 74000 60772";
|
|
const DEFAULT_INV_ADDR = "Pelagia Marine Services Pvt Ltd, 409-410, ZION, Plot 273, Sector-10, Kharghar, Navi Mumbai- 410210 (MH)";
|
|
const DEFAULT_INV_GST = "Email: accounts@pelagiamarine.com GST NO: 27AAHCP5787B1Z6";
|
|
|
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
|
|
function fmtDate(d: Date | null | undefined): string {
|
|
if (!d) return "";
|
|
return new Date(d).toLocaleDateString("en-IN", { day: "2-digit", month: "short", year: "numeric" });
|
|
}
|
|
function fmtNum(n: number, dec = 2): string {
|
|
return n.toLocaleString("en-IN", { minimumFractionDigits: dec, maximumFractionDigits: dec });
|
|
}
|
|
|
|
// Fixed brand bar colour shown at the bottom of every exported PO (matches the sample PO).
|
|
const BRAND_BAR_COLOR = "#92D050";
|
|
|
|
function mimeForKey(key: string): string {
|
|
const ext = key.split(".").pop()?.toLowerCase();
|
|
return ext === "jpg" || ext === "jpeg" ? "image/jpeg" : ext === "webp" ? "image/webp" : "image/png";
|
|
}
|
|
|
|
interface EmbeddedImage { base64: string; mime: string; width: number; height: number }
|
|
|
|
// Download a stored image; return base64 + mime + pixel dimensions (or null if missing).
|
|
async function fetchImage(key: string | null | undefined): Promise<EmbeddedImage | null> {
|
|
if (!key) return null;
|
|
const buf = await downloadBuffer(key);
|
|
if (!buf) return null;
|
|
const size = getImageSize(buf) ?? { width: 100, height: 100 };
|
|
return { base64: buf.toString("base64"), mime: mimeForKey(key), width: size.width, height: size.height };
|
|
}
|
|
|
|
// ── Route ─────────────────────────────────────────────────────────────────────
|
|
|
|
interface Props { params: Promise<{ id: string }> }
|
|
|
|
export async function GET(request: NextRequest, { params }: Props) {
|
|
// PdfService renders this page to a real PDF (issue #14). It authenticates with
|
|
// a short, server-only token instead of a user session — read-only, PDF only.
|
|
const svcToken = request.nextUrl.searchParams.get("svc");
|
|
const isService =
|
|
!!svcToken && !!process.env.PDF_SERVICE_TOKEN && svcToken === process.env.PDF_SERVICE_TOKEN;
|
|
|
|
const session = await auth();
|
|
if (!session?.user && !isService) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
|
|
const { id } = await params;
|
|
const po = await db.purchaseOrder.findUnique({
|
|
where: { id },
|
|
include: {
|
|
submitter: true, vessel: true, account: true,
|
|
company: true,
|
|
vendor: { include: { contacts: { where: { isPrimary: true }, take: 1 } } },
|
|
lineItems: { orderBy: { sortOrder: "asc" } },
|
|
actions: { include: { actor: true }, orderBy: { createdAt: "asc" } },
|
|
},
|
|
});
|
|
if (!po) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
|
|
|
if (!isService) {
|
|
const canViewAll = ["ACCOUNTS", "MANAGER", "SUPERUSER", "AUDITOR", "ADMIN"].includes(session!.user.role);
|
|
if (!canViewAll && po.submitterId !== session!.user.id) {
|
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
|
}
|
|
}
|
|
|
|
// Exports are available for approved POs (manager approval is a prerequisite for a valid PO
|
|
// document) and for CANCELLED POs, which export with a diagonal "CANCELLED" watermark.
|
|
// The submitter's signature is never embedded; only the approving manager's signature is used.
|
|
const EXPORTABLE_STATUSES = ["MGR_APPROVED", "SENT_FOR_PAYMENT", "PAID_DELIVERED", "PARTIALLY_CLOSED", "CLOSED", "CANCELLED"];
|
|
const isCancelled = po.status === "CANCELLED";
|
|
if (!EXPORTABLE_STATUSES.includes(po.status)) {
|
|
return NextResponse.json(
|
|
{ error: "Export is only available for approved purchase orders." },
|
|
{ status: 403 }
|
|
);
|
|
}
|
|
|
|
const format = request.nextUrl.searchParams.get("format") ?? "pdf";
|
|
// pdf=1 → render a clean page for PdfService: no on-screen print button and no
|
|
// window.print() auto-trigger (Chromium's page.pdf() captures it directly).
|
|
const cleanPdf = request.nextUrl.searchParams.get("pdf") === "1";
|
|
|
|
// ── Company data (from linked company, or fallback to constants) ──────────
|
|
const co = po.company;
|
|
const CO_NAME = co?.name ?? DEFAULT_CO_NAME;
|
|
const CO_ADDR = co?.address ? `Office address: ${co.address}` : DEFAULT_CO_ADDR;
|
|
|
|
const telParts = [
|
|
co?.telephone ? `Tel: ${co.telephone}` : null,
|
|
co?.email ? `Email: ${co.email}` : null,
|
|
co?.mobile ? `Mob: ${co.mobile}` : null,
|
|
].filter(Boolean);
|
|
const CO_TEL = telParts.length > 0 ? telParts.join(" / ") : DEFAULT_CO_TEL;
|
|
|
|
const INV_ADDR = co?.invoiceAddress ?? (co?.address ? `${co.name}, ${co.address}` : DEFAULT_INV_ADDR);
|
|
// invoiceEmail takes priority over general email for the Invoice Details line
|
|
const invoiceContactEmail = co?.invoiceEmail ?? co?.email ?? null;
|
|
const INV_GST = [
|
|
invoiceContactEmail ? `Email: ${invoiceContactEmail}` : null,
|
|
co?.gstNumber ? `GST NO: ${co.gstNumber}` : null,
|
|
].filter(Boolean).join(" ") || DEFAULT_INV_GST;
|
|
|
|
// ── Computed data ─────────────────────────────────────────────────────────
|
|
|
|
const items = po.lineItems.map((li, i) => {
|
|
const qty = Number(li.quantity);
|
|
const unitPrice = Number(li.unitPrice);
|
|
const gstRate = Number((li as { gstRate?: unknown }).gstRate ?? 0.18);
|
|
const taxable = Number(li.totalPrice);
|
|
const gstAmt = taxable * gstRate;
|
|
const li_ = li as typeof li & { name?: string };
|
|
const desc = li_.name ?? li.description ?? "";
|
|
// When both name and description exist, include the optional description separately
|
|
const optionalDesc = li_.name && li.description ? li.description : "";
|
|
return { sn: i + 1, desc, optionalDesc, unit: li.unit, qty, unitPrice, gstRate, taxable, gstAmt, total: taxable + gstAmt };
|
|
});
|
|
|
|
const totalTaxable = items.reduce((s, i) => s + i.taxable, 0);
|
|
const totalGst = items.reduce((s, i) => s + i.gstAmt, 0);
|
|
const grandTotal = totalTaxable + totalGst;
|
|
|
|
const approvalAction = [...po.actions].reverse()
|
|
.find(a => a.actionType === "APPROVED" || a.actionType === "APPROVED_WITH_NOTE");
|
|
const approvedBy = approvalAction?.actor.name ?? "";
|
|
|
|
// PO date: submitter-set date → approved date → creation date
|
|
const poDisplayDate = po.poDate ?? po.approvedAt ?? po.createdAt;
|
|
|
|
// Fetch approver's signature for embedding in the document
|
|
let signatureBase64: string | null = null;
|
|
let signatureMime = "image/png";
|
|
let signatureSize: { width: number; height: number } | null = null;
|
|
if (approvalAction) {
|
|
const approver = await db.user.findUnique({
|
|
where: { id: approvalAction.actorId },
|
|
select: { signatureKey: true },
|
|
});
|
|
if (approver?.signatureKey) {
|
|
const buf = await downloadBuffer(approver.signatureKey);
|
|
if (buf) {
|
|
signatureBase64 = buf.toString("base64");
|
|
const ext = approver.signatureKey.split(".").pop()?.toLowerCase();
|
|
signatureMime = ext === "jpg" || ext === "jpeg" ? "image/jpeg" : ext === "webp" ? "image/webp" : "image/png";
|
|
signatureSize = getImageSize(buf) ?? { width: 360, height: 96 };
|
|
}
|
|
}
|
|
}
|
|
|
|
// Company branding (logo top-left, stamp/seal in the signatory block)
|
|
const logoImg = await fetchImage(co?.logoKey);
|
|
const stampImg = await fetchImage(co?.stampKey);
|
|
|
|
const ext = po as {
|
|
piQuotationNo?: string | null; piQuotationDate?: Date | null;
|
|
requisitionNo?: string | null; requisitionDate?: Date | null;
|
|
placeOfDelivery?: string | null;
|
|
tcDelivery?: string | null; tcDispatch?: string | null;
|
|
tcInspection?: string | null; tcTransitInsurance?: string | null;
|
|
tcPaymentTerms?: string | null; tcOthers?: string | null;
|
|
};
|
|
const piNo = ext.piQuotationNo ?? "";
|
|
const piDate = fmtDate(ext.piQuotationDate);
|
|
const reqNo = ext.requisitionNo ?? "";
|
|
const reqDate = fmtDate(ext.requisitionDate);
|
|
const delivery = ext.placeOfDelivery ?? "";
|
|
|
|
const tcLines: [number, string, string][] = [
|
|
[1, "", TC_FIXED_LINE],
|
|
[2, "DELIVERY", ext.tcDelivery ?? TC_DEFAULTS.tcDelivery],
|
|
[3, "DISPATCH INSTRUCTIONS", ext.tcDispatch ?? TC_DEFAULTS.tcDispatch],
|
|
[4, "INSPECTION", ext.tcInspection ?? TC_DEFAULTS.tcInspection],
|
|
[5, "TRANSIT INSURANCE", ext.tcTransitInsurance ?? TC_DEFAULTS.tcTransitInsurance],
|
|
[6, "PAYMENT TERMS", ext.tcPaymentTerms ?? TC_DEFAULTS.tcPaymentTerms],
|
|
...(ext.tcOthers ? [[7, "OTHERS", ext.tcOthers] as [number, string, string]] : []),
|
|
];
|
|
|
|
const vendorAddr = [
|
|
po.vendor?.address,
|
|
po.vendor?.gstin ? `GSTIN: ${po.vendor.gstin}` : null,
|
|
].filter(Boolean).join(" ");
|
|
const primaryContact = po.vendor?.contacts?.[0];
|
|
const vendorContact = [primaryContact?.name, primaryContact?.mobile, primaryContact?.email]
|
|
.filter(Boolean).join(" ");
|
|
|
|
// ── GST summary row label ─────────────────────────────────────────────────
|
|
// If all items share the same GST rate, show it; otherwise show "GST"
|
|
const gstRates = [...new Set(items.map(i => Math.round(i.gstRate * 100)))];
|
|
const gstLabel = gstRates.length === 1 ? `GST (${gstRates[0]}%)` : "GST";
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// XLSX — ExcelJS with full formatting
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
if (format === "xlsx") {
|
|
const wb = new ExcelJS.Workbook();
|
|
wb.creator = "PPMS — Pelagia Payment Management System";
|
|
const ws = wb.addWorksheet(po.poNumber.replace(/\//g, "-"), {
|
|
pageSetup: { paperSize: 9, orientation: "portrait", fitToPage: true, fitToWidth: 1, fitToHeight: 0 },
|
|
});
|
|
|
|
// ── Column widths (A-I) ─────────────────────────────────────────────────
|
|
ws.columns = [
|
|
{ width: 22 }, // A — label column ("Cost Centre", "Vendor Name…")
|
|
{ width: 4 }, // B — spacer; A:B merge = 26 for labels
|
|
{ width: 28 }, // C — main content / descriptions
|
|
{ width: 15 }, // D — secondary labels ("Accounting Code", "Unit")
|
|
{ width: 8 }, // E — small values (Qty, etc.)
|
|
{ width: 15 }, // F — unit price
|
|
{ width: 15 }, // G — labels "Requested By" / "Taxable cost" (was 7 — caused cutoff)
|
|
{ width: 8 }, // H — GST%
|
|
{ width: 16 }, // I — Total cost
|
|
];
|
|
|
|
// ── Style constants ─────────────────────────────────────────────────────
|
|
const thin = (argb = "FF999999") => ({ style: "thin" as const, color: { argb } });
|
|
const med = (argb = "FF555555") => ({ style: "medium" as const, color: { argb } });
|
|
const bordAll = { top: thin(), left: thin(), bottom: thin(), right: thin() };
|
|
const bordMed = { top: med(), left: med(), bottom: med(), right: med() };
|
|
|
|
const ARIAL = "Arial";
|
|
const fBase = { name: ARIAL, size: 9 };
|
|
const fBold = { name: ARIAL, size: 9, bold: true };
|
|
const fTitle = { name: ARIAL, size: 13, bold: true };
|
|
const fH2 = { name: ARIAL, size: 11, bold: true };
|
|
const fSmall = { name: ARIAL, size: 8 };
|
|
|
|
const fillHdr = { type: "pattern" as const, pattern: "solid" as const, fgColor: { argb: "FFD8D8D8" } };
|
|
const fillLbl = { type: "pattern" as const, pattern: "solid" as const, fgColor: { argb: "FFF2F2F2" } };
|
|
const fillTot = { type: "pattern" as const, pattern: "solid" as const, fgColor: { argb: "FFF0F0F0" } };
|
|
const fillGT = { type: "pattern" as const, pattern: "solid" as const, fgColor: { argb: "FFD8D8D8" } };
|
|
const fillInst = { type: "pattern" as const, pattern: "solid" as const, fgColor: { argb: "FFEAEAEA" } };
|
|
|
|
const alignC: Partial<ExcelJS.Alignment> = { horizontal: "center", vertical: "middle", wrapText: true };
|
|
const alignL: Partial<ExcelJS.Alignment> = { horizontal: "left", vertical: "middle", wrapText: true };
|
|
const alignR: Partial<ExcelJS.Alignment> = { horizontal: "right", vertical: "middle" };
|
|
|
|
// Helper: set cell value + style
|
|
function sc(row: number, col: number, value: ExcelJS.CellValue, opts: {
|
|
font?: Partial<ExcelJS.Font>;
|
|
fill?: ExcelJS.Fill;
|
|
border?: Partial<ExcelJS.Borders>;
|
|
align?: Partial<ExcelJS.Alignment>;
|
|
numFmt?: string;
|
|
} = {}) {
|
|
const cell = ws.getCell(row, col);
|
|
cell.value = value;
|
|
if (opts.font) cell.font = opts.font as ExcelJS.Font;
|
|
if (opts.fill) cell.fill = opts.fill;
|
|
if (opts.border) cell.border = opts.border;
|
|
if (opts.align) cell.alignment = opts.align;
|
|
if (opts.numFmt) cell.numFmt = opts.numFmt;
|
|
}
|
|
|
|
// Helper: apply border to a whole row range
|
|
function rowBorder(row: number, c1: number, c2: number, border: Partial<ExcelJS.Borders>) {
|
|
for (let c = c1; c <= c2; c++) ws.getCell(row, c).border = border;
|
|
}
|
|
|
|
// ── Row heights ─────────────────────────────────────────────────────────
|
|
ws.getRow(1).height = 24; // Company name
|
|
ws.getRow(2).height = 16; // Address
|
|
ws.getRow(3).height = 14; // Tel / Email
|
|
ws.getRow(4).height = 20; // PURCHASE ORDER title
|
|
|
|
// ══ ROW 1: Company name ═════════════════════════════════════════════════
|
|
sc(1, 1, CO_NAME, { font: fTitle, align: alignC });
|
|
ws.mergeCells("A1:I1");
|
|
ws.getRow(1).getCell(1).border = { bottom: thin() };
|
|
|
|
// ══ ROW 2: Address ══════════════════════════════════════════════════════
|
|
sc(2, 1, CO_ADDR, { font: fBase, align: alignC });
|
|
ws.mergeCells("A2:I2");
|
|
|
|
// ══ ROW 3: Tel / Email ══════════════════════════════════════════════════
|
|
sc(3, 1, CO_TEL, { font: fSmall, align: alignC });
|
|
ws.mergeCells("A3:I3");
|
|
|
|
// ══ ROW 4: PURCHASE ORDER ════════════════════════════════════════════════
|
|
sc(4, 1, "PURCHASE ORDER", { font: { ...fH2, underline: true }, align: alignC });
|
|
ws.mergeCells("A4:I4");
|
|
ws.getRow(4).border = { top: thin(), bottom: thin() };
|
|
|
|
// ══ Company logo (floats top-left over the header; aspect preserved) ═════
|
|
if (logoImg) {
|
|
const logoId = wb.addImage({
|
|
base64: logoImg.base64,
|
|
extension: logoImg.mime === "image/jpeg" ? "jpeg" : "png",
|
|
});
|
|
ws.addImage(logoId, {
|
|
tl: { col: 0.15, row: 0.2 } as unknown as ExcelJS.Anchor,
|
|
ext: scaleToBox(logoImg, 96, 52),
|
|
editAs: "oneCell",
|
|
});
|
|
}
|
|
|
|
// ══ ROW 5: PO Number & Date ══════════════════════════════════════════════
|
|
ws.getRow(5).height = 18;
|
|
sc(5, 1, "Purchase Order No:", { font: fBold, fill: fillLbl, border: bordAll, align: alignL });
|
|
ws.mergeCells("A5:B5");
|
|
sc(5, 3, po.poNumber, { font: { ...fBold, color: { argb: "FF1A1A1A" } }, border: bordAll, align: alignL });
|
|
ws.mergeCells("C5:G5");
|
|
sc(5, 8, "Date:", { font: fBold, fill: fillLbl, border: bordAll, align: alignR });
|
|
sc(5, 9, fmtDate(poDisplayDate), { font: fBase, border: bordAll, align: alignL });
|
|
|
|
// ══ ROW 6: PI / Quotation ════════════════════════════════════════════════
|
|
ws.getRow(6).height = 16;
|
|
sc(6, 1, "Performa Invoice / Quotation No:", { font: fBold, fill: fillLbl, border: bordAll, align: alignL });
|
|
ws.mergeCells("A6:B6");
|
|
sc(6, 3, piNo, { font: fBase, border: bordAll, align: alignL });
|
|
ws.mergeCells("C6:F6");
|
|
sc(6, 7, "P I / Quotation Date:", { font: fBold, fill: fillLbl, border: bordAll, align: alignR });
|
|
ws.mergeCells("G6:H6");
|
|
sc(6, 9, piDate, { font: fBase, border: bordAll, align: alignL });
|
|
|
|
// ══ ROW 7: Cost Centre / Accounting Code / Requested By ══════════════
|
|
ws.getRow(7).height = 18;
|
|
sc(7, 1, "Cost Centre", { font: fBold, fill: fillLbl, border: bordAll, align: alignL });
|
|
ws.mergeCells("A7:B7");
|
|
sc(7, 3, CO_NAME, { font: fBase, border: bordAll, align: alignL });
|
|
ws.mergeCells("C7:D7"); // span C+D so company name has room
|
|
sc(7, 5, "Accounting Code",{ font: fBold, fill: fillLbl, border: bordAll, align: alignC });
|
|
sc(7, 6, po.account.code, { font: fBase, border: bordAll, align: alignC });
|
|
sc(7, 7, "Requested By", { font: fBold, fill: fillLbl, border: bordAll, align: alignC });
|
|
sc(7, 8, po.submitter.name,{ font: fBase, border: bordAll, align: alignL });
|
|
ws.mergeCells("H7:I7");
|
|
|
|
// ══ ROW 8: Requisition / Reqn. Date / Approved By ════════════════════
|
|
ws.getRow(8).height = 18;
|
|
sc(8, 1, "Cost Centre/Office Requisition No.",{ font: fBold, fill: fillLbl, border: bordAll, align: alignL });
|
|
ws.mergeCells("A8:B8");
|
|
sc(8, 3, reqNo, { font: fBase, border: bordAll, align: alignL });
|
|
ws.mergeCells("C8:D8");
|
|
sc(8, 5, "Reqn. Date", { font: fBold, fill: fillLbl, border: bordAll, align: alignC });
|
|
sc(8, 6, reqDate, { font: fBase, border: bordAll, align: alignC });
|
|
sc(8, 7, "Approved By", { font: fBold, fill: fillLbl, border: bordAll, align: alignC });
|
|
sc(8, 8, approvedBy, { font: fBase, border: bordAll, align: alignL });
|
|
ws.mergeCells("H8:I8");
|
|
|
|
// ══ ROWS 9-10: Place of Delivery (2-row span) ═══════════════════════
|
|
ws.getRow(9).height = 20;
|
|
ws.getRow(10).height = 20;
|
|
sc(9, 1, "Place of Delivery", { font: fBold, fill: fillLbl, border: bordAll, align: alignC });
|
|
ws.mergeCells("A9:B10");
|
|
sc(9, 3, delivery, { font: fBase, border: bordAll, align: alignL });
|
|
ws.mergeCells("C9:I10");
|
|
|
|
// ══ ROWS 11-12: Invoice Details (2-row span) ════════════════════════
|
|
ws.getRow(11).height = 18;
|
|
ws.getRow(12).height = 16;
|
|
sc(11, 1, "Invoice Details", { font: fBold, fill: fillLbl, border: bordAll, align: alignC });
|
|
ws.mergeCells("A11:B12");
|
|
sc(11, 3, INV_ADDR, { font: fSmall, border: { top: thin(), left: thin(), right: thin() }, align: alignL });
|
|
ws.mergeCells("C11:I11");
|
|
sc(12, 3, INV_GST, { font: fSmall, border: { bottom: thin(), left: thin(), right: thin() }, align: alignL });
|
|
ws.mergeCells("C12:I12");
|
|
|
|
// ══ ROW 13: Vendor Name & Address ═══════════════════════════════════
|
|
ws.getRow(13).height = 32;
|
|
sc(13, 1, "Vendor Name & Address", { font: fBold, fill: fillLbl, border: bordAll, align: alignC });
|
|
ws.mergeCells("A13:B13");
|
|
sc(13, 3, po.vendor?.name ?? "—", { font: fBold, border: bordAll, align: alignL });
|
|
sc(13, 4, vendorAddr, { font: fSmall, border: bordAll, align: alignL });
|
|
ws.mergeCells("D13:I13");
|
|
|
|
// ══ ROW 14: Contact ═════════════════════════════════════════════════
|
|
ws.getRow(14).height = 18;
|
|
sc(14, 1, "Contact Person name / mobile no.", { font: fBold, fill: fillLbl, border: bordAll, align: alignC });
|
|
ws.mergeCells("A14:B14");
|
|
sc(14, 3, vendorContact, { font: fSmall, border: bordAll, align: alignL });
|
|
ws.mergeCells("C14:I14");
|
|
|
|
// ══ ROW 15: Line item header ═════════════════════════════════════════
|
|
const HDR_ROW = 15;
|
|
ws.getRow(HDR_ROW).height = 16;
|
|
const hdrStyle = { font: fBold, fill: fillHdr, border: bordAll };
|
|
sc(HDR_ROW, 1, "S.N.", { ...hdrStyle, align: alignC });
|
|
sc(HDR_ROW, 2, "Description", { ...hdrStyle, align: alignC });
|
|
ws.mergeCells(`B${HDR_ROW}:C${HDR_ROW}`);
|
|
sc(HDR_ROW, 4, "Unit", { ...hdrStyle, align: alignC });
|
|
sc(HDR_ROW, 5, "Qnty", { ...hdrStyle, align: alignC });
|
|
sc(HDR_ROW, 6, "Unit price", { ...hdrStyle, align: alignC });
|
|
sc(HDR_ROW, 7, "Taxable cost",{ ...hdrStyle, align: alignC });
|
|
sc(HDR_ROW, 8, "GST%", { ...hdrStyle, align: alignC });
|
|
sc(HDR_ROW, 9, "Total cost", { ...hdrStyle, align: alignC });
|
|
|
|
// ══ Line items ═══════════════════════════════════════════════════════
|
|
const NUM_FMT = '#,##0.00';
|
|
const BODY_ROWS = Math.max(items.length, 7); // reserve at least 7 rows
|
|
for (let idx = 0; idx < BODY_ROWS; idx++) {
|
|
const r = HDR_ROW + 1 + idx;
|
|
// Taller rows: long item names + potential description sub-line need room
|
|
const descLen = (items[idx]?.desc ?? "").length + (items[idx]?.optionalDesc ?? "").length;
|
|
ws.getRow(r).height = descLen > 40 || items[idx]?.optionalDesc ? 32 : 20;
|
|
const item = items[idx];
|
|
const fillAlt = idx % 2 === 1
|
|
? { type: "pattern" as const, pattern: "solid" as const, fgColor: { argb: "FFFAFAFA" } }
|
|
: undefined;
|
|
|
|
sc(r, 1, item?.sn ?? null, { font: fBase, fill: fillAlt, border: bordAll, align: alignC });
|
|
const xlsxDesc = item
|
|
? (item.optionalDesc ? `${item.desc}\n${item.optionalDesc}` : item.desc)
|
|
: "";
|
|
sc(r, 2, xlsxDesc, { font: fBase, fill: fillAlt, border: bordAll, align: alignL });
|
|
ws.mergeCells(`B${r}:C${r}`);
|
|
sc(r, 4, item?.unit ?? "", { font: fBase, fill: fillAlt, border: bordAll, align: alignC });
|
|
sc(r, 5, item ? item.qty : null, { font: fBase, fill: fillAlt, border: bordAll, align: alignR, numFmt: NUM_FMT });
|
|
sc(r, 6, item ? item.unitPrice : null, { font: fBase, fill: fillAlt, border: bordAll, align: alignR, numFmt: NUM_FMT });
|
|
sc(r, 7, item ? item.taxable : null, { font: fBase, fill: fillAlt, border: bordAll, align: alignR, numFmt: NUM_FMT });
|
|
sc(r, 8, item ? item.gstRate : null, { font: fBase, fill: fillAlt, border: bordAll, align: alignC, numFmt: '0%' });
|
|
sc(r, 9, item ? item.total : null, { font: { ...fBase, bold: !!item }, fill: fillAlt, border: bordAll, align: alignR, numFmt: NUM_FMT });
|
|
}
|
|
|
|
// ══ Totals ════════════════════════════════════════════════════════════
|
|
const TOT_ROW = HDR_ROW + 1 + BODY_ROWS + 1;
|
|
ws.getRow(TOT_ROW).height = 14;
|
|
ws.getRow(TOT_ROW + 1).height = 14;
|
|
ws.getRow(TOT_ROW + 2).height = 16;
|
|
|
|
// "Total taxable value"
|
|
sc(TOT_ROW, 6, "Total taxable value", { font: fBold, fill: fillTot, border: bordAll, align: alignR });
|
|
ws.mergeCells(`F${TOT_ROW}:G${TOT_ROW}`);
|
|
sc(TOT_ROW, 8, totalTaxable, { font: fBold, fill: fillTot, border: bordAll, align: alignR, numFmt: NUM_FMT });
|
|
ws.mergeCells(`H${TOT_ROW}:I${TOT_ROW}`);
|
|
|
|
// "GST"
|
|
sc(TOT_ROW + 1, 6, gstLabel, { font: fBold, fill: fillTot, border: bordAll, align: alignR });
|
|
ws.mergeCells(`F${TOT_ROW + 1}:G${TOT_ROW + 1}`);
|
|
sc(TOT_ROW + 1, 8, totalGst, { font: fBold, fill: fillTot, border: bordAll, align: alignR, numFmt: NUM_FMT });
|
|
ws.mergeCells(`H${TOT_ROW + 1}:I${TOT_ROW + 1}`);
|
|
|
|
// "GRAND TOTAL"
|
|
sc(TOT_ROW + 2, 6, "GRAND TOTAL", { font: { ...fBold, size: 10 }, fill: fillGT, border: bordAll, align: alignR });
|
|
ws.mergeCells(`F${TOT_ROW + 2}:G${TOT_ROW + 2}`);
|
|
sc(TOT_ROW + 2, 8, grandTotal, { font: { ...fBold, size: 10 }, fill: fillGT, border: bordAll, align: alignR, numFmt: NUM_FMT });
|
|
ws.mergeCells(`H${TOT_ROW + 2}:I${TOT_ROW + 2}`);
|
|
|
|
// ══ Instructions ═════════════════════════════════════════════════════
|
|
const INST_ROW = TOT_ROW + 4;
|
|
ws.getRow(INST_ROW).height = 16;
|
|
sc(INST_ROW, 1, "INSTRUCTIONS TO VENDORS", { font: { ...fBold, size: 10 }, fill: fillInst, border: bordAll, align: alignC });
|
|
ws.mergeCells(`A${INST_ROW}:I${INST_ROW}`);
|
|
|
|
tcLines.forEach(([num, label, value], i) => {
|
|
const r = INST_ROW + 1 + i;
|
|
ws.getRow(r).height = 14;
|
|
sc(r, 1, num, { font: fBase, border: bordAll, align: alignC });
|
|
const text = label ? `${label}: ${value}` : value;
|
|
sc(r, 2, text, { font: fBase, border: bordAll, align: alignL });
|
|
ws.mergeCells(`B${r}:I${r}`);
|
|
});
|
|
|
|
// ══ Signatures ═══════════════════════════════════════════════════════
|
|
const SIG_ROW = INST_ROW + tcLines.length + 3;
|
|
ws.getRow(SIG_ROW).height = 40;
|
|
ws.getRow(SIG_ROW + 1).height = 14;
|
|
ws.getRow(SIG_ROW + 2).height = 14;
|
|
|
|
// Left signatory block (cols A-D). Position images by absolute pixels via native
|
|
// EMU offsets — ExcelJS's fractional-column anchors don't map cleanly to pixels.
|
|
const EMU = 9525; // EMU per pixel
|
|
const COL_PX = [22, 4, 28, 15, 8, 15, 15, 8, 16].map((w) => Math.round(w * 7 + 5));
|
|
const SIG_BLOCK_PX = COL_PX[0] + COL_PX[1] + COL_PX[2] + COL_PX[3]; // A-D
|
|
const anchorAt = (leftPx: number, row: number) => {
|
|
let x = 0;
|
|
for (let c = 0; c < COL_PX.length - 1; c++) {
|
|
if (leftPx < x + COL_PX[c]) {
|
|
return { nativeCol: c, nativeColOff: Math.round((leftPx - x) * EMU), nativeRow: row, nativeRowOff: 0 } as unknown as ExcelJS.Anchor;
|
|
}
|
|
x += COL_PX[c];
|
|
}
|
|
return { nativeCol: COL_PX.length - 1, nativeColOff: Math.round((leftPx - x) * EMU), nativeRow: row, nativeRowOff: 0 } as unknown as ExcelJS.Anchor;
|
|
};
|
|
|
|
const sigExt = signatureBase64 ? scaleToBox(signatureSize ?? { width: 360, height: 96 }, 165, 44) : null;
|
|
const stampExt = stampImg ? scaleToBox(stampImg, 80, 66) : null;
|
|
// Signature centred over the name; stamp to its RIGHT with a gap (no overlap).
|
|
const { sigLeft, stampLeft } = signatoryLayout({ blockPx: SIG_BLOCK_PX, sig: sigExt, stamp: stampExt });
|
|
|
|
// Stamp / seal — drawn FIRST so it layers BEHIND the signature if they ever touch.
|
|
if (stampImg && stampExt && stampLeft != null) {
|
|
const stampId = wb.addImage({
|
|
base64: stampImg.base64,
|
|
extension: stampImg.mime === "image/jpeg" ? "jpeg" : "png",
|
|
});
|
|
ws.addImage(stampId, {
|
|
tl: anchorAt(stampLeft, SIG_ROW - 1),
|
|
ext: stampExt,
|
|
editAs: "oneCell",
|
|
});
|
|
}
|
|
|
|
// Approver signature — drawn AFTER the stamp (on top), centred over the name.
|
|
if (signatureBase64 && sigExt && sigLeft != null) {
|
|
const imgType = signatureMime === "image/jpeg" ? "jpeg" : "png";
|
|
const imgId = wb.addImage({ base64: signatureBase64, extension: imgType });
|
|
ws.addImage(imgId, {
|
|
tl: anchorAt(Math.max(0, sigLeft), SIG_ROW - 1),
|
|
ext: sigExt,
|
|
editAs: "oneCell",
|
|
});
|
|
sc(SIG_ROW, 1, "", { border: { top: thin(), left: thin(), right: thin() } });
|
|
} else {
|
|
sc(SIG_ROW, 1, approvedBy, { font: fBold, border: { top: thin(), left: thin(), right: thin() }, align: alignC });
|
|
}
|
|
ws.mergeCells(`A${SIG_ROW}:D${SIG_ROW}`);
|
|
sc(SIG_ROW + 1, 1, approvedBy, { font: fBold, border: { left: thin(), right: thin() }, align: alignC });
|
|
ws.mergeCells(`A${SIG_ROW + 1}:D${SIG_ROW + 1}`);
|
|
sc(SIG_ROW + 2, 1, "Authorized Signatory & Stamp", { font: fSmall, border: { left: thin(), right: thin() }, align: alignC });
|
|
ws.mergeCells(`A${SIG_ROW + 2}:D${SIG_ROW + 2}`);
|
|
sc(SIG_ROW + 3, 1, "For, Pelagia Marine Services Pvt. Ltd.", { font: fSmall, border: { left: thin(), bottom: thin(), right: thin() }, align: alignC });
|
|
ws.mergeCells(`A${SIG_ROW + 3}:D${SIG_ROW + 3}`);
|
|
// Adjust row heights when signature present
|
|
ws.getRow(SIG_ROW + 1).height = 14;
|
|
ws.getRow(SIG_ROW + 2).height = 14;
|
|
ws.getRow(SIG_ROW + 3).height = 14;
|
|
|
|
// Right sig block (vendor)
|
|
const vName = po.vendor?.name ?? "";
|
|
sc(SIG_ROW, 6, vName, { font: fBold, border: { top: thin(), left: thin(), right: thin() }, align: alignC });
|
|
ws.mergeCells(`F${SIG_ROW}:I${SIG_ROW}`);
|
|
sc(SIG_ROW + 1, 6, "Authorized Signatory & Stamp", { font: fSmall, border: { left: thin(), right: thin() }, align: alignC });
|
|
ws.mergeCells(`F${SIG_ROW + 1}:I${SIG_ROW + 1}`);
|
|
sc(SIG_ROW + 2, 6, `For, ${vName}`, { font: fSmall, border: { left: thin(), bottom: thin(), right: thin() }, align: alignC });
|
|
ws.mergeCells(`F${SIG_ROW + 2}:I${SIG_ROW + 2}`);
|
|
|
|
// ══ Brand bar (full-width colour strip at the very bottom) ═══════════════
|
|
const BAR_ROW = SIG_ROW + 4;
|
|
const barArgb = "FF" + BRAND_BAR_COLOR.replace("#", "").toUpperCase();
|
|
const barFill = { type: "pattern" as const, pattern: "solid" as const, fgColor: { argb: barArgb } };
|
|
ws.getRow(BAR_ROW).height = 16;
|
|
for (let c = 1; c <= 9; c++) sc(BAR_ROW, c, "", { fill: barFill });
|
|
ws.mergeCells(`A${BAR_ROW}:I${BAR_ROW}`);
|
|
|
|
// ══ Cancelled watermark — diagonal "CANCELLED" centred over the sheet ════
|
|
// Pixel-sized (aspect preserved) so the text spans the page like the PDF,
|
|
// rather than being stretched/squished by a cell-range anchor.
|
|
if (isCancelled) {
|
|
const wmId = wb.addImage({ base64: CANCELLED_WATERMARK_PNG_BASE64, extension: "png" });
|
|
const ext = scaleToBox({ width: CANCELLED_WATERMARK_W, height: CANCELLED_WATERMARK_H }, 880, 720);
|
|
ws.addImage(wmId, {
|
|
tl: { col: 0.15, row: 5 } as unknown as ExcelJS.Anchor,
|
|
ext,
|
|
editAs: "oneCell",
|
|
});
|
|
}
|
|
|
|
// ── Serialise ─────────────────────────────────────────────────────────
|
|
const buf = await wb.xlsx.writeBuffer();
|
|
const slug = po.poNumber.replace(/\//g, "-");
|
|
return new NextResponse(Buffer.from(buf), {
|
|
headers: {
|
|
"Content-Type": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
"Content-Disposition": `attachment; filename="${slug}.xlsx"`,
|
|
},
|
|
});
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// PDF — HTML print page matching sample layout
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
const itemRows = items.map((item, i) => `
|
|
<tr style="background:${i % 2 === 0 ? "#fff" : "#fafafa"}">
|
|
<td style="text-align:center">${item.sn}</td>
|
|
<td>${item.desc}${item.optionalDesc ? `<br/><span style="font-size:7.5pt;color:#666;font-style:italic">${item.optionalDesc}</span>` : ""}</td>
|
|
<td style="text-align:center">${item.unit}</td>
|
|
<td style="text-align:right">${fmtNum(item.qty, item.qty % 1 === 0 ? 0 : 3)}</td>
|
|
<td style="text-align:right">${fmtNum(item.unitPrice)}</td>
|
|
<td style="text-align:right">${fmtNum(item.taxable)}</td>
|
|
<td style="text-align:center">${Math.round(item.gstRate * 100)}%</td>
|
|
<td style="text-align:right;font-weight:bold">${fmtNum(item.total)}</td>
|
|
</tr>`).join("");
|
|
|
|
// Pad to at least 7 rows so the table doesn't collapse
|
|
const blankRows = Math.max(0, 7 - items.length);
|
|
const padRows = Array(blankRows).fill(
|
|
'<tr style="height:20px"><td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td></tr>'
|
|
).join("");
|
|
|
|
const termRows = tcLines.map(([num, label, value]) => `
|
|
<tr>
|
|
<td style="text-align:center;width:24px;font-weight:bold">${num}.</td>
|
|
<td>${label ? `<b>${label}:</b> ` : ""}${value}</td>
|
|
</tr>`).join("");
|
|
|
|
const html = `<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8"/>
|
|
<title>${po.poNumber} — Purchase Order</title>
|
|
<style>
|
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
body {
|
|
font-family: Arial, Helvetica, sans-serif;
|
|
font-size: 8.5pt;
|
|
color: #111;
|
|
margin: 10mm 12mm;
|
|
line-height: 1.3;
|
|
-webkit-print-color-adjust: exact;
|
|
print-color-adjust: exact;
|
|
}
|
|
|
|
/* ── Header ── */
|
|
.header-band { position: relative; }
|
|
.co-logo {
|
|
position: absolute;
|
|
left: 0;
|
|
top: 0;
|
|
max-height: 52px;
|
|
max-width: 92px;
|
|
object-fit: contain;
|
|
}
|
|
.co-name {
|
|
text-align: center;
|
|
font-size: 13pt;
|
|
font-weight: bold;
|
|
border-bottom: 1px solid #555;
|
|
padding-bottom: 3px;
|
|
margin-bottom: 1px;
|
|
}
|
|
.co-addr, .co-tel {
|
|
text-align: center;
|
|
font-size: 8pt;
|
|
}
|
|
.po-title {
|
|
text-align: center;
|
|
font-size: 11pt;
|
|
font-weight: bold;
|
|
text-decoration: underline;
|
|
border-top: 1.5px solid #333;
|
|
border-bottom: 1.5px solid #333;
|
|
padding: 3px 0;
|
|
margin: 4px 0;
|
|
}
|
|
|
|
/* ── Meta tables ── */
|
|
table { width: 100%; border-collapse: collapse; }
|
|
.meta td { border: 1px solid #999; padding: 3px 5px; vertical-align: middle; font-size: 8.5pt; }
|
|
.meta td.lbl { background: #f2f2f2; font-weight: bold; white-space: nowrap; }
|
|
|
|
/* ── Line items table ── */
|
|
.items th {
|
|
background: #d8d8d8;
|
|
font-weight: bold;
|
|
border: 1px solid #888;
|
|
padding: 4px 5px;
|
|
text-align: center;
|
|
font-size: 8.5pt;
|
|
}
|
|
.items td {
|
|
border: 1px solid #999;
|
|
padding: 3px 5px;
|
|
vertical-align: middle;
|
|
font-size: 8.5pt;
|
|
}
|
|
|
|
/* ── Totals ── */
|
|
.totals { width: 55%; margin-left: auto; margin-top: 0; border-collapse: collapse; }
|
|
.totals td { border: 1px solid #999; padding: 3px 8px; font-size: 8.5pt; }
|
|
.totals .tot-lbl { background: #f0f0f0; font-weight: bold; text-align: right; }
|
|
.totals .tot-val { background: #f0f0f0; text-align: right; font-weight: bold; min-width: 90px; }
|
|
.totals .gt-lbl { background: #d8d8d8; font-weight: bold; text-align: right; font-size: 9pt; }
|
|
.totals .gt-val { background: #d8d8d8; text-align: right; font-weight: bold; font-size: 9pt; min-width: 90px; }
|
|
|
|
/* ── Instructions ── */
|
|
.inst-hdr td { background: #eaeaea; font-weight: bold; font-size: 9pt; text-align: center; border: 1px solid #888; padding: 4px; }
|
|
.inst-body td { border: none; padding: 2px 3px; font-size: 8pt; vertical-align: top; }
|
|
|
|
/* ── Signatures ── */
|
|
.sig { display: flex; justify-content: space-between; margin-top: 14px; }
|
|
.sig-box {
|
|
position: relative;
|
|
border: 1px solid #999;
|
|
width: 44%;
|
|
min-height: 60px;
|
|
padding: 6px 10px;
|
|
text-align: center;
|
|
display: flex;
|
|
flex-direction: column;
|
|
justify-content: space-between;
|
|
}
|
|
.sig-name { font-weight: bold; font-size: 9pt; min-height: 32px; }
|
|
.sig-sub { font-size: 7.5pt; }
|
|
.sig-stamp {
|
|
position: absolute;
|
|
right: 6px;
|
|
top: 4px;
|
|
max-height: 66px;
|
|
max-width: 88px;
|
|
object-fit: contain;
|
|
pointer-events: none;
|
|
}
|
|
|
|
.spacer { margin: 4px 0; }
|
|
|
|
/* ── Brand bar (bottom) ── */
|
|
.brand-bar {
|
|
height: 14px;
|
|
width: 100%;
|
|
margin-top: 12px;
|
|
background: ${BRAND_BAR_COLOR};
|
|
}
|
|
|
|
/* ── Cancelled watermark ── */
|
|
.cancelled-watermark {
|
|
position: fixed;
|
|
top: 50%;
|
|
left: 50%;
|
|
transform: translate(-50%, -50%) rotate(-35deg);
|
|
font-size: 96pt;
|
|
font-weight: 800;
|
|
letter-spacing: 8px;
|
|
color: rgba(200, 0, 0, 0.18);
|
|
border: 6px solid rgba(200, 0, 0, 0.18);
|
|
padding: 8px 32px;
|
|
border-radius: 8px;
|
|
white-space: nowrap;
|
|
z-index: 9999;
|
|
pointer-events: none;
|
|
}
|
|
|
|
@media print {
|
|
.no-print { display: none; }
|
|
body { margin: 8mm 10mm; }
|
|
@page { size: A4 portrait; margin: 0; }
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
|
|
${isCancelled ? `<div class="cancelled-watermark">CANCELLED</div>` : ""}
|
|
|
|
${cleanPdf ? "" : `<div class="no-print" style="margin-bottom:8px">
|
|
<button onclick="window.print()" style="padding:5px 14px;font-size:11px;cursor:pointer;border:1px solid #999;border-radius:4px">
|
|
🖨 Print / Save as PDF
|
|
</button>
|
|
</div>`}
|
|
|
|
<!-- ── Header ─────────────────────────────────────────────────── -->
|
|
<div class="header-band">
|
|
${logoImg ? `<img class="co-logo" src="data:${logoImg.mime};base64,${logoImg.base64}" alt="Logo" />` : ""}
|
|
<div class="co-name">${CO_NAME}</div>
|
|
<div class="co-addr">${CO_ADDR}</div>
|
|
<div class="co-tel">${CO_TEL}</div>
|
|
</div>
|
|
<div class="po-title">PURCHASE ORDER</div>
|
|
|
|
<!-- ── PO Meta & Quotation ──────────────────────────────────── -->
|
|
<table class="meta" style="margin-bottom:0">
|
|
<tr>
|
|
<td class="lbl" style="width:22%">Purchase Order No:</td>
|
|
<td style="width:28%;font-weight:bold">${po.poNumber}</td>
|
|
<td class="lbl" style="width:14%;text-align:right">Date:</td>
|
|
<td style="width:36%">${fmtDate(poDisplayDate)}</td>
|
|
</tr>
|
|
<tr>
|
|
<td class="lbl">Performa Invoice / Quotation No:</td>
|
|
<td>${piNo}</td>
|
|
<td class="lbl" style="text-align:right">P I / Quotation Date:</td>
|
|
<td>${piDate}</td>
|
|
</tr>
|
|
</table>
|
|
|
|
<!-- ── Cost Centre / Account / Requested By ──────────────────── -->
|
|
<table class="meta" style="margin-bottom:0">
|
|
<tr>
|
|
<td class="lbl" style="width:22%">Cost Centre</td>
|
|
<td style="width:24%">Pelagia Marine Services Pvt. Ltd.</td>
|
|
<td class="lbl" style="width:12%;text-align:center">Accounting Code</td>
|
|
<td style="width:8%;text-align:center">${po.account.code}</td>
|
|
<td class="lbl" style="width:14%">Requested By</td>
|
|
<td style="width:20%">${po.submitter.name}</td>
|
|
</tr>
|
|
<tr>
|
|
<td class="lbl">Cost Centre/Office Requisition Number</td>
|
|
<td>${reqNo}</td>
|
|
<td class="lbl" style="text-align:center">Reqn. Date</td>
|
|
<td style="text-align:center">${reqDate}</td>
|
|
<td class="lbl">Approved By</td>
|
|
<td>${approvedBy}</td>
|
|
</tr>
|
|
</table>
|
|
|
|
<!-- ── Place of Delivery ─────────────────────────────────────── -->
|
|
<table class="meta" style="margin-bottom:0">
|
|
<tr>
|
|
<td class="lbl" style="width:22%">Place of Delivery</td>
|
|
<td>${delivery}</td>
|
|
</tr>
|
|
</table>
|
|
|
|
<!-- ── Invoice Details ───────────────────────────────────────── -->
|
|
<table class="meta" style="margin-bottom:0">
|
|
<tr>
|
|
<td class="lbl" style="width:22%;vertical-align:middle" rowspan="2">Invoice Details</td>
|
|
<td style="border-bottom:none">${INV_ADDR}</td>
|
|
</tr>
|
|
<tr>
|
|
<td style="border-top:none;font-size:8pt">${INV_GST}</td>
|
|
</tr>
|
|
</table>
|
|
|
|
<!-- ── Vendor ────────────────────────────────────────────────── -->
|
|
<table class="meta" style="margin-bottom:4px">
|
|
<tr>
|
|
<td class="lbl" style="width:22%">Vendor Name & Address</td>
|
|
<td style="width:24%;font-weight:bold">${po.vendor?.name ?? "—"}</td>
|
|
<td colspan="2">${vendorAddr}</td>
|
|
</tr>
|
|
<tr>
|
|
<td class="lbl">Contact Person name / mobile no.</td>
|
|
<td colspan="3">${vendorContact}</td>
|
|
</tr>
|
|
</table>
|
|
|
|
<!-- ── Line Items ────────────────────────────────────────────── -->
|
|
<table class="items">
|
|
<thead>
|
|
<tr>
|
|
<th style="width:4%">S.N.</th>
|
|
<th style="width:30%">Description</th>
|
|
<th style="width:6%;text-align:center">Unit</th>
|
|
<th style="width:7%;text-align:right">Qnty</th>
|
|
<th style="width:11%;text-align:right">Unit price</th>
|
|
<th style="width:14%;text-align:right">Taxable cost</th>
|
|
<th style="width:7%;text-align:center">GST%</th>
|
|
<th style="width:14%;text-align:right">Total cost</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
${itemRows}
|
|
${padRows}
|
|
</tbody>
|
|
</table>
|
|
|
|
<!-- ── Totals ────────────────────────────────────────────────── -->
|
|
<table class="totals">
|
|
<tr>
|
|
<td class="tot-lbl">Total taxable value</td>
|
|
<td class="tot-val">${fmtNum(totalTaxable)}</td>
|
|
</tr>
|
|
<tr>
|
|
<td class="tot-lbl">${gstLabel}</td>
|
|
<td class="tot-val">${fmtNum(totalGst)}</td>
|
|
</tr>
|
|
<tr>
|
|
<td class="gt-lbl">GRAND TOTAL</td>
|
|
<td class="gt-val">${fmtNum(grandTotal)}</td>
|
|
</tr>
|
|
</table>
|
|
|
|
<div class="spacer"></div>
|
|
|
|
<!-- ── Instructions to Vendors ───────────────────────────────── -->
|
|
<table style="margin-bottom:6px">
|
|
<tr class="inst-hdr"><td colspan="2">INSTRUCTIONS TO VENDORS</td></tr>
|
|
${termRows}
|
|
</table>
|
|
|
|
<!-- ── Signatures ────────────────────────────────────────────── -->
|
|
<div class="sig">
|
|
<div class="sig-box">
|
|
${stampImg ? `<img class="sig-stamp" src="data:${stampImg.mime};base64,${stampImg.base64}" alt="Stamp" />` : ""}
|
|
${signatureBase64
|
|
? `<img src="data:${signatureMime};base64,${signatureBase64}" alt="Signature" style="max-height:48px;max-width:180px;object-fit:contain;display:block;margin:0 auto 4px;" />`
|
|
: `<div class="sig-name">${approvedBy}</div>`
|
|
}
|
|
<div>
|
|
<div class="sig-sub" style="font-weight:bold">${approvedBy}</div>
|
|
<div class="sig-sub">Authorized Signatory & Stamp</div>
|
|
<div class="sig-sub">For, ${CO_NAME}</div>
|
|
</div>
|
|
</div>
|
|
<div class="sig-box">
|
|
<div class="sig-name">${po.vendor?.name ?? ""}</div>
|
|
<div>
|
|
<div class="sig-sub">Authorized Signatory & Stamp</div>
|
|
<div class="sig-sub">For, ${po.vendor?.name ?? ""}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ── Brand bar ─────────────────────────────────────────────── -->
|
|
<div class="brand-bar"></div>
|
|
|
|
${cleanPdf ? "" : `<script>window.onload = function() { window.print(); };</script>`}
|
|
</body>
|
|
</html>`;
|
|
|
|
return new NextResponse(html, { headers: { "Content-Type": "text/html; charset=utf-8" } });
|
|
}
|