From 5cb8b228b14f68ceb1ff6503ecdf84a6afbc589a Mon Sep 17 00:00:00 2001 From: Hardik Date: Wed, 6 May 2026 00:15:57 +0530 Subject: [PATCH] feat(export): individual PO export as PDF and XLSX matching Sample_PO format MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../app/api/po/[id]/export/route.ts | 391 ++++++++++++++++++ 1 file changed, 391 insertions(+) create mode 100644 App/pelagia-portal/app/api/po/[id]/export/route.ts diff --git a/App/pelagia-portal/app/api/po/[id]/export/route.ts b/App/pelagia-portal/app/api/po/[id]/export/route.ts new file mode 100644 index 0000000..63f2d23 --- /dev/null +++ b/App/pelagia-portal/app/api/po/[id]/export/route.ts @@ -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) => ` + + ${item.sn} + ${item.desc} + ${item.unit} + ${fmt(item.qty, 3)} + ${fmt(item.unitPrice)} + ${fmt(item.taxable)} + ${Math.round(item.gstRate * 100)}% + ${fmt(item.total)} + `).join(""); + + const termLines = tcLines.map(([num, label, value]) => + `
  • ${num}. ${label ? `${label}: ` : ""}${value}
  • ` + ).join(""); + + const html = ` + + + +${po.poNumber} — Purchase Order + + + +
    + +
    + + +

    ${COMPANY_NAME}

    +

    ${COMPANY_ADDR}

    +

    ${COMPANY_TEL}

    +
    +

    PURCHASE ORDER

    +
    + + + + + + + + + + + + + + + +
    Purchase Order No:${po.poNumber}Date:${fmtDate(po.createdAt)}
    PI / Quotation No:${piNo}PI / Quotation Date:${piDate}
    + + + + + + + + + + + + + + + + + + + +
    Vessel Owner NamePelagia Marine Services Pvt. Ltd.Budget Head${po.account.code}Requested By${po.submitter.name}
    Vessel/Office Req. No.${reqNo}Reqn. Date${reqDate}Approved By${approvedBy}
    + + + + + + + +
    Place of Delivery${placeOfDelivery}
    + + + + + + + +
    Invoice Details${INVOICE_ADDR}
    ${INVOICE_GST}
    + + + + + + + + + + + + +
    Vendor Name & Address${po.vendor?.name ?? "—"}${[po.vendor?.address, po.vendor?.gstin ? `GSTIN: ${po.vendor.gstin}` : ""].filter(Boolean).join(" ")}
    Contact Person / Mobile${[po.vendor?.contactName, po.vendor?.contactMobile, po.vendor?.contactEmail].filter(Boolean).join(" ")}
    + + + + + + + + + + + + + + + + + ${itemRows} + ${Array(Math.max(0, 6 - items.length)).fill('').join("")} + +
    S.N.DescriptionUnitQtyUnit PriceTaxable CostGST%Total Cost
     
    + + + + + + + + + + + + + + + +
    Total Taxable Value${fmt(totalTaxable)}
    GST (${Math.round((items[0]?.gstRate ?? 0.18) * 100)}%)${fmt(totalGst)}
    GRAND TOTAL (INR)${fmt(grandTotal)}
    + + + + + +
    INSTRUCTIONS TO VENDORS
      ${termLines}
    + + +
    +
    +

    ${po.submitter.name}

    +

    Authorized Signatory & Stamp

    +

    For, Pelagia Marine Services Pvt. Ltd.

    +
    +
    +

    ${po.vendor?.name ?? ""}

    +

    Authorized Signatory & Stamp

    +

    For, ${po.vendor?.name ?? ""}

    +
    +
    + + + +`; + + return new NextResponse(html, { + headers: { "Content-Type": "text/html; charset=utf-8" }, + }); +}