pelagia-portal/App/app/api/po/[id]/export/route.ts
Hardik cc7251e6b7 feat: Cost Centre covers vessels and sites, vessel codes, Accounting Code rename, vessel-site assignment
- Undo Vessel→Cost Centre rename in admin (admin shows "Vessel Management" again)
- Sidebar: "Cost Centres"→"Vessels", "Accounts"→"Accounting Codes"
- PO forms (new/edit/import/manager-edit) now show both Vessels (with code) and Sites in the
  Cost Centre dropdown, encoded as v:<id> / s:<id> via a costCentreRef field
- vesselId on PurchaseOrder is now nullable; siteId is set when a site is the cost centre
- History, approvals, dashboard, my-orders, payments display vessel.name ?? site.name as Cost Centre
- History and approvals cost centre filters use costCentreRef URL param supporting both types
- Admin vessel form: adds Site assignment dropdown
- Admin accounts: renamed to "Accounting Code" throughout (pages, forms, sidebar)
- PO detail and exports: "Account" label renamed to "Accounting Code"
- Site detail: "Assigned Vessels (Cost Centres)" heading; vessel detail breadcrumb fixed
- Create PO links from vessel/site detail use ?costCentreRef= param
- Export routes handle costCentreRef filter param (with legacy vesselId fallback)
- DB migration: ALTER TABLE PurchaseOrder ALTER COLUMN vesselId DROP NOT NULL
- CLAUDE.md updated with Cost Centre Model documentation

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 03:04:29 +05:30

714 lines
35 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";
// ── Company constants ─────────────────────────────────────────────────────────
const CO_NAME = "PELAGIA MARINE SERVICES PVT. LTD";
const CO_ADDR = "Office address: 409-410, ZION, Plot 273, Sector-10, Kharghar, Navi Mumbai- 410210";
const CO_TEL = "Tel: +91-22-6909 9028 / Email: technical@pelagiamarine.com / Mob: +91 74000 60772";
const INV_ADDR = "Pelagia Marine Services Pvt Ltd, 409-410, ZION, Plot 273, Sector-10, Kharghar, Navi Mumbai- 410210 (MH)";
const 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 });
}
// ── Route ─────────────────────────────────────────────────────────────────────
interface Props { params: Promise<{ id: string }> }
export async function GET(request: NextRequest, { params }: Props) {
const session = await auth();
if (!session?.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const { id } = await params;
const po = await db.purchaseOrder.findUnique({
where: { id },
include: {
submitter: true, vessel: true, site: { select: { name: true } }, account: 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 });
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 only available for approved POs — manager approval is a prerequisite for a valid PO document.
// 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"];
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";
// ── 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 ?? "";
return { sn: i + 1, desc, 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 ?? "";
// Fetch approver's signature for embedding in the document
let signatureBase64: string | null = null;
let signatureMime = "image/png";
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";
}
}
}
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: 20 }, // A
{ width: 4 }, // B
{ width: 22 }, // C
{ width: 10 }, // D
{ width: 7 }, // E
{ width: 16 }, // F
{ width: 7 }, // G
{ width: 7 }, // H
{ width: 16 }, // I
];
// ── 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 = 22;
ws.getRow(2).height = 14;
ws.getRow(3).height = 14;
ws.getRow(4).height = 18;
// ══ 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() };
// ══ ROW 5: PO Number & Date ══════════════════════════════════════════════
ws.getRow(5).height = 16;
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(po.createdAt), { font: fBase, border: bordAll, align: alignL });
// ══ ROW 6: PI / Quotation ════════════════════════════════════════════════
ws.getRow(6).height = 14;
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 / Account / Requested By ═══════════════════════
ws.getRow(7).height = 14;
sc(7, 1, "Cost Centre", { font: fBold, fill: fillLbl, border: bordAll, align: alignL });
ws.mergeCells("A7:B7");
sc(7, 3, "Pelagia Marine Services Pvt. Ltd.", { font: fBase, border: bordAll, align: alignL });
sc(7, 4, "Accounting Code", { font: fBold, fill: fillLbl, border: bordAll, align: alignC });
ws.mergeCells("D7:E7");
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 / Approved By ═════════════════════════════════
ws.getRow(8).height = 14;
sc(8, 1, "Cost Centre/Office Requisition Number", { font: fBold, fill: fillLbl, border: bordAll, align: alignL });
ws.mergeCells("A8:B8");
sc(8, 3, reqNo, { font: fBase, border: bordAll, align: alignL });
sc(8, 4, "Reqn. Date", { font: fBold, fill: fillLbl, border: bordAll, align: alignC });
ws.mergeCells("D8:E8");
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 = 14;
ws.getRow(10).height = 14;
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 = 14;
ws.getRow(12).height = 13;
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 = 28;
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 = 14;
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;
ws.getRow(r).height = 15;
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 });
sc(r, 2, item?.desc ?? "", { 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 sig block (approver — the manager who authorized the PO)
if (signatureBase64) {
const imgType = signatureMime === "image/jpeg" ? "jpeg" : "png";
const imgId = wb.addImage({ base64: signatureBase64, extension: imgType });
// Span the image across columns A-D in the sig row
ws.addImage(imgId, {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
tl: { col: 0, row: SIG_ROW - 1 } as any,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
br: { col: 4, row: SIG_ROW } as any,
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}`);
// ── 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}</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;
}
/* ── Header ── */
.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 {
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; }
.spacer { margin: 4px 0; }
@media print {
.no-print { display: none; }
body { margin: 8mm 10mm; }
@page { size: A4 portrait; margin: 0; }
}
</style>
</head>
<body>
<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="co-name">${CO_NAME}</div>
<div class="co-addr">${CO_ADDR}</div>
<div class="co-tel">${CO_TEL}</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(po.createdAt)}</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 &amp; 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">
${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 &amp; Stamp</div>
<div class="sig-sub">For, Pelagia Marine Services Pvt. Ltd.</div>
</div>
</div>
<div class="sig-box">
<div class="sig-name">${po.vendor?.name ?? ""}</div>
<div>
<div class="sig-sub">Authorized Signatory &amp; Stamp</div>
<div class="sig-sub">For, ${po.vendor?.name ?? ""}</div>
</div>
</div>
</div>
<script>window.onload = function() { window.print(); };</script>
</body>
</html>`;
return new NextResponse(html, { headers: { "Content-Type": "text/html; charset=utf-8" } });
}