feat(export): match Sample_PO.xlsx formatting in XLSX and PDF exports

XLSX (ExcelJS replaces bare SheetJS):
- Full cell-level styling: bold fonts, gray fills, thin/medium borders,
  cell alignment, wrap-text — all matching the sample PO layout
- Correct merge map across all sections:
  header block (A1:I4), PO meta row (A5:B5, C5:G5), PI/Quotation row
  (A6:B6, C6:F6, G6:H6), Vessel/Budget/Requested row (A7:B7, D7:E7,
  H7:I7), Requisition row (A8:B8, D8:E8, H8:I8), Place of Delivery
  spanning 2 rows (A9:B10, C9:I10), Invoice Details spanning 2 rows
  (A11:B12, C11:I11, C12:I12), Vendor block (A13:B13, D13:I13, A14:B14,
  C14:I14), line item Description column (B:C per row), totals labels
  and values (F:G and H:I), instructions header (A:I), T&C text (B:I
  per row), dual signature blocks
- Description column spans B:C in header and every item row
- Minimum 7 body rows reserved; alternating row fills
- Totals section: gray fill, right-aligned, grand total darker gray
- Instructions header with distinct fill; numbered T&C with B:I merge
- Paired signature blocks (submitter left, vendor right) with borders
- Column widths and row heights tuned to the sample dimensions
- Page setup: A4 portrait, fit-to-width

PDF (HTML print page):
- Typography matches sample: Arial 8.5pt body, 13pt bold company name,
  11pt underlined PURCHASE ORDER heading
- All meta tables use gray (f2f2f2) label cells with borders
- Place of Delivery and Invoice Details use rowspan for correct layout
- Line items table: dark gray (d8d8d8) header, 1px borders, alternating
  row fills, minimum 7 blank rows reserved
- Totals table (55% width, right-aligned) with gray rows and darker
  grand total
- Instructions: distinct header fill, clean numbered layout
- Signature blocks: flex-spaced bordered boxes with submitter/vendor
- Print CSS: A4 page size, no-print class for the print button

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Hardik 2026-05-10 00:28:34 +05:30
parent 48e1f19e58
commit 2b5e125260
3 changed files with 1010 additions and 241 deletions

View file

@ -1,22 +1,28 @@
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { NextRequest, NextResponse } from "next/server";
import * as XLSX from "xlsx";
import ExcelJS from "exceljs";
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";
// ── Company constants ─────────────────────────────────────────────────────────
function fmt(n: number, dec = 2) {
return n.toLocaleString("en-IN", { minimumFractionDigits: dec, maximumFractionDigits: dec });
}
function fmtDate(d: Date | null | undefined) {
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 }> }
@ -28,17 +34,13 @@ export async function GET(request: NextRequest, { params }: Props) {
const po = await db.purchaseOrder.findUnique({
where: { id },
include: {
submitter: true,
vessel: true,
account: true,
vendor: true,
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 });
@ -46,38 +48,39 @@ export async function GET(request: NextRequest, { params }: Props) {
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; },
}));
// ── 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;
return { sn: i + 1, desc: li.description, 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");
.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 {
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;
};
// Build ordered TC lines: [number, label, value]
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],
@ -88,296 +91,561 @@ export async function GET(request: NextRequest, { params }: Props) {
...(ext.tcOthers ? [[7, "OTHERS", ext.tcOthers] as [number, string, string]] : []),
];
// ── XLSX ────────────────────────────────────────────────────────────────────
const vendorAddr = [
po.vendor?.address,
po.vendor?.gstin ? `GSTIN: ${po.vendor.gstin}` : null,
].filter(Boolean).join(" ");
const vendorContact = [po.vendor?.contactName, po.vendor?.contactMobile, po.vendor?.contactEmail]
.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 = 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);
const wb = new ExcelJS.Workbook();
wb.creator = "Pelagia Portal";
const ws = wb.addWorksheet(po.poNumber.replace(/\//g, "-"), {
pageSetup: { paperSize: 9, orientation: "portrait", fitToPage: true, fitToWidth: 1, fitToHeight: 0 },
});
// 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 },
// ── 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
];
XLSX.utils.book_append_sheet(wb, ws, po.poNumber.replace(/\//g, "-"));
const buf = XLSX.write(wb, { type: "buffer", bookType: "xlsx" });
// ── 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() };
return new NextResponse(buf, {
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: Vessel Owner / Budget / Requested By ═══════════════════════
ws.getRow(7).height = 14;
sc(7, 1, "Vessel Owner Name", { 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, "Budget head", { 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, "Vessel/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 (submitter)
sc(SIG_ROW, 1, po.submitter.name, { font: fBold, border: { top: thin(), left: thin(), right: thin() }, align: alignC });
ws.mergeCells(`A${SIG_ROW}:D${SIG_ROW}`);
sc(SIG_ROW + 1, 1, "Authorized Signatory & Stamp", { font: fSmall, border: { left: thin(), right: thin() }, align: alignC });
ws.mergeCells(`A${SIG_ROW + 1}:D${SIG_ROW + 1}`);
sc(SIG_ROW + 2, 1, "For, Pelagia Marine Services Pvt. Ltd.", { font: fSmall, border: { left: thin(), bottom: thin(), right: thin() }, align: alignC });
ws.mergeCells(`A${SIG_ROW + 2}:D${SIG_ROW + 2}`);
// 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="${po.poNumber.replace(/\//g, "-")}.xlsx"`,
"Content-Disposition": `attachment; filename="${slug}.xlsx"`,
},
});
}
// ── PDF (HTML print page) ───────────────────────────────────────────────────
// ═══════════════════════════════════════════════════════════════════════════
// 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">${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: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"><b>${fmt(item.total)}</b></td>
<td style="text-align:right;font-weight:bold">${fmtNum(item.total)}</td>
</tr>`).join("");
const termLines = tcLines.map(([num, label, value]) =>
`<li style="margin-bottom:3px"><b>${num}.</b> ${label ? `<b>${label}:</b> ` : ""}${value}</li>`
// 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; }
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; } }
* { 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:10px">
<button onclick="window.print()" style="padding:6px 16px;font-size:12px;cursor:pointer;">Print / Save as PDF</button>
<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 -->
<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"/>
<!-- ── 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 -->
<table>
<!-- ── PO Meta & Quotation ──────────────────────────────────── -->
<table class="meta" style="margin-bottom:0">
<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 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="bold">PI / Quotation No:</td>
<td class="lbl">Performa Invoice / Quotation No:</td>
<td>${piNo}</td>
<td class="bold" style="text-align:right">PI / Quotation Date:</td>
<td class="lbl" style="text-align:right">P I / Quotation Date:</td>
<td>${piDate}</td>
</tr>
</table>
<!-- Vessel / Req -->
<table>
<!-- ── Vessel / Budget / Requested By ───────────────────────── -->
<table class="meta" style="margin-bottom:0">
<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>
<td class="lbl" style="width:22%">Vessel Owner Name</td>
<td style="width:24%">Pelagia Marine Services Pvt. Ltd.</td>
<td class="lbl" style="width:12%;text-align:center">Budget head</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="bold">Vessel/Office Req. No.</td>
<td class="lbl">Vessel/Office Requisition Number</td>
<td>${reqNo}</td>
<td class="bold" style="text-align:center">Reqn. Date</td>
<td class="lbl" style="text-align:center">Reqn. Date</td>
<td style="text-align:center">${reqDate}</td>
<td class="bold">Approved By</td>
<td class="lbl">Approved By</td>
<td>${approvedBy}</td>
</tr>
</table>
<!-- Delivery -->
<table>
<!-- ── Place of Delivery ─────────────────────────────────────── -->
<table class="meta" style="margin-bottom:0">
<tr>
<td class="bold" style="width:22%">Place of Delivery</td>
<td>${placeOfDelivery}</td>
<td class="lbl" style="width:22%">Place of Delivery</td>
<td>${delivery}</td>
</tr>
</table>
<!-- Invoice details -->
<table>
<!-- ── Invoice Details ───────────────────────────────────────── -->
<table class="meta" style="margin-bottom:0">
<tr>
<td class="bold" style="width:22%">Invoice Details</td>
<td>${INVOICE_ADDR}<br/>${INVOICE_GST}</td>
<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>
<!-- ── Vendor ────────────────────────────────────────────────── -->
<table class="meta" style="margin-bottom:4px">
<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>
<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="bold">Contact Person / Mobile</td>
<td colspan="2">${[po.vendor?.contactName, po.vendor?.contactMobile, po.vendor?.contactEmail].filter(Boolean).join(" ")}</td>
<td class="lbl">Contact Person name / mobile no.</td>
<td colspan="3">${vendorContact}</td>
</tr>
</table>
<!-- Line Items -->
<table>
<!-- ── Line Items ────────────────────────────────────────────── -->
<table class="items">
<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: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>
<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("")}
${padRows}
</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>
<!-- ── Totals ────────────────────────────────────────────────── -->
<table class="totals">
<tr>
<td class="tot-lbl">Total taxable value</td>
<td class="tot-val">${fmtNum(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>
<td class="tot-lbl">${gstLabel}</td>
<td class="tot-val">${fmtNum(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>
<td class="gt-lbl">GRAND TOTAL</td>
<td class="gt-val">${fmtNum(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>
<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>
<!-- Signature -->
<div class="sig-block">
<!-- ── Signatures ────────────────────────────────────────────── -->
<div class="sig">
<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 class="sig-name">${po.submitter.name}</div>
<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">
<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 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>
@ -385,7 +653,5 @@ export async function GET(request: NextRequest, { params }: Props) {
</body>
</html>`;
return new NextResponse(html, {
headers: { "Content-Type": "text/html; charset=utf-8" },
});
return new NextResponse(html, { headers: { "Content-Type": "text/html; charset=utf-8" } });
}

View file

@ -43,6 +43,7 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"exceljs": "^4.4.0",
"lucide-react": "^0.468.0",
"next": "^15.1.0",
"next-auth": "^5.0.0-beta.25",

File diff suppressed because it is too large Load diff