feat(export): individual PO export as PDF and XLSX matching Sample_PO format

GET /api/po/[id]/export?format=pdf — HTML print page; company header, PO meta grid,
vendor block with GSTIN, line items table with taxable/GST%/total columns,
totals (taxable subtotal, GST, grand total), numbered T&C list, dual signature block.
GET /api/po/[id]/export?format=xlsx — SheetJS workbook matching Sample_PO.xlsx column layout.
Export PDF / Export XLSX buttons added to PO detail header.
This commit is contained in:
Hardik 2026-05-06 00:15:57 +05:30
parent 0d053d9bd4
commit 5cb8b228b1

View file

@ -0,0 +1,391 @@
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { NextRequest, NextResponse } from "next/server";
import * as XLSX from "xlsx";
import { TC_FIXED_LINE, TC_DEFAULTS } from "@/lib/validations/po";
const COMPANY_NAME = "PELAGIA MARINE SERVICES PVT. LTD";
const COMPANY_ADDR = "409-410, ZION, Plot 273, Sector-10, Kharghar, Navi Mumbai- 410210";
const COMPANY_TEL = "Tel: +91-22-6909 9028 / Email: technical@pelagiamarine.com / Mob: +91 74000 60772";
const INVOICE_ADDR = "Pelagia Marine Services Pvt Ltd, 409-410, ZION, Plot 273, Sector-10, Kharghar, Navi Mumbai- 410210 (MH)";
const INVOICE_GST = "Email: accounts@pelagiamarine.com GST NO: 27AAHCP5787B1Z6";
function fmt(n: number, dec = 2) {
return n.toLocaleString("en-IN", { minimumFractionDigits: dec, maximumFractionDigits: dec });
}
function fmtDate(d: Date | null | undefined) {
if (!d) return "";
return new Date(d).toLocaleDateString("en-IN", { day: "2-digit", month: "short", year: "numeric" });
}
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,
account: true,
vendor: true,
lineItems: { orderBy: { sortOrder: "asc" } },
actions: { include: { actor: true }, orderBy: { createdAt: "asc" } },
},
});
if (!po) return NextResponse.json({ error: "Not found" }, { status: 404 });
// Permission: submitter can always export their own PO
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 });
}
const format = request.nextUrl.searchParams.get("format") ?? "pdf";
// Compute totals
const items = po.lineItems.map((li) => ({
sn: li.sortOrder + 1,
desc: li.description,
unit: li.unit,
qty: Number(li.quantity),
unitPrice: Number(li.unitPrice),
gstRate: Number((li as { gstRate?: unknown }).gstRate ?? 0.18),
taxable: Number(li.totalPrice),
get gstAmt() { return this.taxable * this.gstRate; },
get total() { return this.taxable + this.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 ?? "";
const piNo = (po as { piQuotationNo?: string | null }).piQuotationNo ?? "";
const piDate = fmtDate((po as { piQuotationDate?: Date | null }).piQuotationDate);
const reqNo = (po as { requisitionNo?: string | null }).requisitionNo ?? "";
const reqDate = fmtDate((po as { requisitionDate?: Date | null }).requisitionDate);
const placeOfDelivery = (po as { placeOfDelivery?: string | null }).placeOfDelivery ?? "";
const ext = po as {
tcDelivery?: string | null; tcDispatch?: string | null;
tcInspection?: string | null; tcTransitInsurance?: string | null;
tcPaymentTerms?: string | null; tcOthers?: string | null;
};
// Build ordered TC lines: [number, label, value]
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]] : []),
];
// ── XLSX ────────────────────────────────────────────────────────────────────
if (format === "xlsx") {
const wb = XLSX.utils.book_new();
const ws: XLSX.WorkSheet = {};
function s(r: number, c: number, v: string | number | Date, opts?: XLSX.CellObject) {
const addr = XLSX.utils.encode_cell({ r, c });
ws[addr] = { v, t: typeof v === "number" ? "n" : v instanceof Date ? "d" : "s", ...opts };
}
function n(v: number): XLSX.CellObject { return { v, t: "n" }; }
// Header
s(0, 0, COMPANY_NAME);
s(1, 0, COMPANY_ADDR);
s(2, 0, COMPANY_TEL);
s(3, 0, "PURCHASE ORDER");
// PO meta
s(4, 0, "Purchase Order No:");
s(4, 2, po.poNumber);
s(4, 7, "Date:");
s(4, 8, new Date(po.createdAt).toLocaleDateString("en-IN"));
s(5, 0, "Performa Invoice / Quotation No:");
s(5, 2, piNo);
s(5, 6, "P I / Quotation Date:");
s(5, 8, piDate);
// Vessel / Requisition
s(6, 0, "Vessel Owner Name");
s(6, 2, "Pelagia Marine Services Pvt. Ltd.");
s(6, 3, "Budget head");
s(6, 5, po.account.code);
s(6, 6, "Requested By");
s(6, 7, po.submitter.name);
s(7, 0, "Vessel/Office Requisition Number");
s(7, 2, reqNo);
s(7, 3, "Reqn. Date");
s(7, 5, reqDate);
s(7, 6, "Approved By");
s(7, 7, approvedBy);
// Delivery
s(8, 0, "Place of Delivery");
s(8, 2, placeOfDelivery);
// Invoice details
s(10, 0, "Invoice Details");
s(10, 2, INVOICE_ADDR);
s(11, 2, INVOICE_GST);
// Vendor
s(12, 0, "Vendor Name & Address");
s(12, 2, po.vendor?.name ?? "");
s(12, 3, [po.vendor?.address ?? "", po.vendor?.gstin ? `GSTIN: ${po.vendor.gstin}` : ""].filter(Boolean).join(" "));
s(13, 0, "Contact Person name / mobile no.");
s(13, 2, [po.vendor?.contactName, po.vendor?.contactMobile, po.vendor?.contactEmail].filter(Boolean).join(" "));
// Line item header
const LH = 14;
s(LH, 0, "S.N.");
s(LH, 1, "Description");
s(LH, 3, "Unit");
s(LH, 4, "Qnty");
s(LH, 5, "Unit price");
s(LH, 6, "Taxable cost");
s(LH, 7, "GST%");
s(LH, 8, "Total cost");
// Line items
items.forEach((item, idx) => {
const r = LH + 1 + idx;
ws[XLSX.utils.encode_cell({ r, c: 0 })] = n(item.sn);
s(r, 1, item.desc);
s(r, 3, item.unit);
ws[XLSX.utils.encode_cell({ r, c: 4 })] = n(item.qty);
ws[XLSX.utils.encode_cell({ r, c: 5 })] = n(item.unitPrice);
ws[XLSX.utils.encode_cell({ r, c: 6 })] = n(item.taxable);
ws[XLSX.utils.encode_cell({ r, c: 7 })] = n(item.gstRate);
ws[XLSX.utils.encode_cell({ r, c: 8 })] = n(item.total);
});
// Totals
const TR = LH + items.length + 1 + 6; // leave blank rows up to row 23 (0-indexed)
s(TR, 5, "Total taxable value");
ws[XLSX.utils.encode_cell({ r: TR, c: 7 })] = n(totalTaxable);
s(TR + 1, 5, `GST (${Math.round(items[0]?.gstRate * 100 || 18)}%)`);
ws[XLSX.utils.encode_cell({ r: TR + 1, c: 7 })] = n(totalGst);
s(TR + 2, 5, "GRAND TOTAL");
ws[XLSX.utils.encode_cell({ r: TR + 2, c: 7 })] = n(grandTotal);
// Instructions
const IR = TR + 4;
s(IR, 0, "INSTRUCTIONS TO VENDORS");
tcLines.forEach(([num, label, value], idx) => {
ws[XLSX.utils.encode_cell({ r: IR + 1 + idx, c: 0 })] = { v: num, t: "n" };
s(IR + 1 + idx, 1, label ? `${label}: ${value}` : value);
});
// Signature
const SR = IR + tcLines.length + 3;
s(SR, 0, po.submitter.name);
s(SR + 1, 0, "Authorized Signatory & Stamp");
s(SR + 1, 6, "Authorized Signatory & Stamp");
s(SR + 2, 0, "For, Pelagia Marine Services Pvt. Ltd.");
s(SR + 2, 5, "For");
s(SR + 2, 6, po.vendor?.name ?? "");
ws["!ref"] = XLSX.utils.encode_range({ r: 0, c: 0 }, { r: SR + 3, c: 8 });
ws["!cols"] = [
{ wch: 22 }, { wch: 30 }, { wch: 22 }, { wch: 8 }, { wch: 8 },
{ wch: 14 }, { wch: 14 }, { wch: 8 }, { wch: 14 },
];
XLSX.utils.book_append_sheet(wb, ws, po.poNumber.replace(/\//g, "-"));
const buf = XLSX.write(wb, { type: "buffer", bookType: "xlsx" });
return new NextResponse(buf, {
headers: {
"Content-Type": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"Content-Disposition": `attachment; filename="${po.poNumber.replace(/\//g, "-")}.xlsx"`,
},
});
}
// ── PDF (HTML print page) ───────────────────────────────────────────────────
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">${fmt(item.qty, 3)}</td>
<td style="text-align:right">${fmt(item.unitPrice)}</td>
<td style="text-align:right">${fmt(item.taxable)}</td>
<td style="text-align:center">${Math.round(item.gstRate * 100)}%</td>
<td style="text-align:right"><b>${fmt(item.total)}</b></td>
</tr>`).join("");
const termLines = tcLines.map(([num, label, value]) =>
`<li style="margin-bottom:3px"><b>${num}.</b> ${label ? `<b>${label}:</b> ` : ""}${value}</li>`
).join("");
const html = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<title>${po.poNumber} Purchase Order</title>
<style>
* { box-sizing: border-box; }
body { font-family: Arial, sans-serif; font-size: 9pt; margin: 12mm; color: #111; }
h1 { font-size: 14pt; text-align: center; margin: 0 0 2px; }
.center { text-align: center; }
.right { text-align: right; }
.bold { font-weight: bold; }
table { width: 100%; border-collapse: collapse; margin-bottom: 4px; }
th, td { border: 1px solid #999; padding: 3px 5px; vertical-align: top; font-size: 8pt; }
th { background: #e8e8e8; font-weight: bold; }
.no-border td, .no-border th { border: none; padding: 1px 3px; }
.section-header td { background: #d8d8d8; font-weight: bold; text-align: center; padding: 3px; }
.totals-row td { background: #f0f0f0; }
.grand-total td { background: #d8d8d8; font-weight: bold; font-size: 9pt; }
.sig-block { margin-top: 12px; display: flex; justify-content: space-between; }
.sig-box { border: 1px solid #999; padding: 8px 16px; min-height: 50px; width: 45%; text-align: center; }
ol { margin: 2px 0 0 14px; padding: 0; }
li { margin-bottom: 2px; }
@media print { .no-print { display: none; } }
</style>
</head>
<body>
<div class="no-print" style="margin-bottom:10px">
<button onclick="window.print()" style="padding:6px 16px;font-size:12px;cursor:pointer;">Print / Save as PDF</button>
</div>
<!-- Header -->
<h1>${COMPANY_NAME}</h1>
<p class="center" style="margin:0">${COMPANY_ADDR}</p>
<p class="center" style="margin:0 0 4px">${COMPANY_TEL}</p>
<hr style="border-top:1.5px solid #333;margin:4px 0"/>
<h2 style="text-align:center;font-size:12pt;margin:2px 0 4px">PURCHASE ORDER</h2>
<hr style="border-top:1.5px solid #333;margin:4px 0 6px"/>
<!-- PO Meta -->
<table>
<tr>
<td class="bold" style="width:22%">Purchase Order No:</td>
<td class="bold" style="width:24%">${po.poNumber}</td>
<td class="bold" style="width:18%;text-align:right">Date:</td>
<td style="width:36%">${fmtDate(po.createdAt)}</td>
</tr>
<tr>
<td class="bold">PI / Quotation No:</td>
<td>${piNo}</td>
<td class="bold" style="text-align:right">PI / Quotation Date:</td>
<td>${piDate}</td>
</tr>
</table>
<!-- Vessel / Req -->
<table>
<tr>
<td class="bold" style="width:22%">Vessel Owner Name</td>
<td style="width:20%">Pelagia Marine Services Pvt. Ltd.</td>
<td class="bold" style="width:12%;text-align:center">Budget Head</td>
<td style="width:10%;text-align:center">${po.account.code}</td>
<td class="bold" style="width:14%">Requested By</td>
<td style="width:22%">${po.submitter.name}</td>
</tr>
<tr>
<td class="bold">Vessel/Office Req. No.</td>
<td>${reqNo}</td>
<td class="bold" style="text-align:center">Reqn. Date</td>
<td style="text-align:center">${reqDate}</td>
<td class="bold">Approved By</td>
<td>${approvedBy}</td>
</tr>
</table>
<!-- Delivery -->
<table>
<tr>
<td class="bold" style="width:22%">Place of Delivery</td>
<td>${placeOfDelivery}</td>
</tr>
</table>
<!-- Invoice details -->
<table>
<tr>
<td class="bold" style="width:22%">Invoice Details</td>
<td>${INVOICE_ADDR}<br/>${INVOICE_GST}</td>
</tr>
</table>
<!-- Vendor -->
<table>
<tr>
<td class="bold" style="width:22%">Vendor Name &amp; Address</td>
<td style="width:22%">${po.vendor?.name ?? "—"}</td>
<td>${[po.vendor?.address, po.vendor?.gstin ? `GSTIN: ${po.vendor.gstin}` : ""].filter(Boolean).join(" ")}</td>
</tr>
<tr>
<td class="bold">Contact Person / Mobile</td>
<td colspan="2">${[po.vendor?.contactName, po.vendor?.contactMobile, po.vendor?.contactEmail].filter(Boolean).join(" ")}</td>
</tr>
</table>
<!-- Line Items -->
<table>
<thead>
<tr>
<th style="width:4%">S.N.</th>
<th style="width:30%">Description</th>
<th style="width:7%">Unit</th>
<th style="width:7%;text-align:right">Qty</th>
<th style="width:10%;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}
${Array(Math.max(0, 6 - items.length)).fill('<tr><td>&nbsp;</td><td></td><td></td><td></td><td></td><td></td><td></td><td></td></tr>').join("")}
</tbody>
</table>
<!-- Totals -->
<table style="width:50%;margin-left:auto">
<tr class="totals-row">
<td class="bold" style="text-align:right">Total Taxable Value</td>
<td style="text-align:right;width:35%">${fmt(totalTaxable)}</td>
</tr>
<tr class="totals-row">
<td class="bold" style="text-align:right">GST (${Math.round((items[0]?.gstRate ?? 0.18) * 100)}%)</td>
<td style="text-align:right">${fmt(totalGst)}</td>
</tr>
<tr class="grand-total">
<td style="text-align:right">GRAND TOTAL (INR)</td>
<td style="text-align:right">${fmt(grandTotal)}</td>
</tr>
</table>
<!-- T&C -->
<table style="margin-top:6px">
<tr class="section-header"><td>INSTRUCTIONS TO VENDORS</td></tr>
<tr><td><ul style="list-style:none;padding:0;margin:0">${termLines}</ul></td></tr>
</table>
<!-- Signature -->
<div class="sig-block">
<div class="sig-box">
<p style="margin:0 0 24px;font-weight:bold">${po.submitter.name}</p>
<p style="margin:0;font-size:8pt">Authorized Signatory &amp; Stamp</p>
<p style="margin:0;font-size:8pt">For, Pelagia Marine Services Pvt. Ltd.</p>
</div>
<div class="sig-box">
<p style="margin:0 0 24px;font-weight:bold">${po.vendor?.name ?? ""}</p>
<p style="margin:0;font-size:8pt">Authorized Signatory &amp; Stamp</p>
<p style="margin:0;font-size:8pt">For, ${po.vendor?.name ?? ""}</p>
</div>
</div>
<script>window.onload = function() { window.print(); };</script>
</body>
</html>`;
return new NextResponse(html, {
headers: { "Content-Type": "text/html; charset=utf-8" },
});
}