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:
parent
0d053d9bd4
commit
5cb8b228b1
1 changed files with 391 additions and 0 deletions
391
App/pelagia-portal/app/api/po/[id]/export/route.ts
Normal file
391
App/pelagia-portal/app/api/po/[id]/export/route.ts
Normal 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 & 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> </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 & 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 & 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" },
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue