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:
parent
48e1f19e58
commit
2b5e125260
3 changed files with 1010 additions and 241 deletions
|
|
@ -1,22 +1,28 @@
|
||||||
import { auth } from "@/auth";
|
import { auth } from "@/auth";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
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";
|
import { TC_FIXED_LINE, TC_DEFAULTS } from "@/lib/validations/po";
|
||||||
|
|
||||||
const COMPANY_NAME = "PELAGIA MARINE SERVICES PVT. LTD";
|
// ── Company constants ─────────────────────────────────────────────────────────
|
||||||
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) {
|
const CO_NAME = "PELAGIA MARINE SERVICES PVT. LTD";
|
||||||
return n.toLocaleString("en-IN", { minimumFractionDigits: dec, maximumFractionDigits: dec });
|
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";
|
||||||
function fmtDate(d: Date | null | undefined) {
|
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 "";
|
if (!d) return "";
|
||||||
return new Date(d).toLocaleDateString("en-IN", { day: "2-digit", month: "short", year: "numeric" });
|
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 }> }
|
interface Props { params: Promise<{ id: string }> }
|
||||||
|
|
||||||
|
|
@ -28,17 +34,13 @@ export async function GET(request: NextRequest, { params }: Props) {
|
||||||
const po = await db.purchaseOrder.findUnique({
|
const po = await db.purchaseOrder.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
include: {
|
include: {
|
||||||
submitter: true,
|
submitter: true, vessel: true, account: true, vendor: true,
|
||||||
vessel: true,
|
|
||||||
account: true,
|
|
||||||
vendor: true,
|
|
||||||
lineItems: { orderBy: { sortOrder: "asc" } },
|
lineItems: { orderBy: { sortOrder: "asc" } },
|
||||||
actions: { include: { actor: true }, orderBy: { createdAt: "asc" } },
|
actions: { include: { actor: true }, orderBy: { createdAt: "asc" } },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!po) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
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);
|
const canViewAll = ["ACCOUNTS", "MANAGER", "SUPERUSER", "AUDITOR", "ADMIN"].includes(session.user.role);
|
||||||
if (!canViewAll && po.submitterId !== session.user.id) {
|
if (!canViewAll && po.submitterId !== session.user.id) {
|
||||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
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";
|
const format = request.nextUrl.searchParams.get("format") ?? "pdf";
|
||||||
|
|
||||||
// Compute totals
|
// ── Computed data ─────────────────────────────────────────────────────────
|
||||||
const items = po.lineItems.map((li) => ({
|
|
||||||
sn: li.sortOrder + 1,
|
const items = po.lineItems.map((li, i) => {
|
||||||
desc: li.description,
|
const qty = Number(li.quantity);
|
||||||
unit: li.unit,
|
const unitPrice = Number(li.unitPrice);
|
||||||
qty: Number(li.quantity),
|
const gstRate = Number((li as { gstRate?: unknown }).gstRate ?? 0.18);
|
||||||
unitPrice: Number(li.unitPrice),
|
const taxable = Number(li.totalPrice);
|
||||||
gstRate: Number((li as { gstRate?: unknown }).gstRate ?? 0.18),
|
const gstAmt = taxable * gstRate;
|
||||||
taxable: Number(li.totalPrice),
|
return { sn: i + 1, desc: li.description, unit: li.unit, qty, unitPrice, gstRate, taxable, gstAmt, total: taxable + gstAmt };
|
||||||
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 totalTaxable = items.reduce((s, i) => s + i.taxable, 0);
|
||||||
const totalGst = items.reduce((s, i) => s + i.gstAmt, 0);
|
const totalGst = items.reduce((s, i) => s + i.gstAmt, 0);
|
||||||
const grandTotal = totalTaxable + totalGst;
|
const grandTotal = totalTaxable + totalGst;
|
||||||
|
|
||||||
const approvalAction = [...po.actions].reverse()
|
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 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 {
|
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;
|
tcDelivery?: string | null; tcDispatch?: string | null;
|
||||||
tcInspection?: string | null; tcTransitInsurance?: string | null;
|
tcInspection?: string | null; tcTransitInsurance?: string | null;
|
||||||
tcPaymentTerms?: string | null; tcOthers?: 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][] = [
|
const tcLines: [number, string, string][] = [
|
||||||
[1, "", TC_FIXED_LINE],
|
[1, "", TC_FIXED_LINE],
|
||||||
[2, "DELIVERY", ext.tcDelivery ?? TC_DEFAULTS.tcDelivery],
|
[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]] : []),
|
...(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") {
|
if (format === "xlsx") {
|
||||||
const wb = XLSX.utils.book_new();
|
const wb = new ExcelJS.Workbook();
|
||||||
const ws: XLSX.WorkSheet = {};
|
wb.creator = "Pelagia Portal";
|
||||||
|
const ws = wb.addWorksheet(po.poNumber.replace(/\//g, "-"), {
|
||||||
function s(r: number, c: number, v: string | number | Date, opts?: XLSX.CellObject) {
|
pageSetup: { paperSize: 9, orientation: "portrait", fitToPage: true, fitToWidth: 1, fitToHeight: 0 },
|
||||||
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
|
// ── Column widths (A-I) ─────────────────────────────────────────────────
|
||||||
const SR = IR + tcLines.length + 3;
|
ws.columns = [
|
||||||
s(SR, 0, po.submitter.name);
|
{ width: 20 }, // A
|
||||||
s(SR + 1, 0, "Authorized Signatory & Stamp");
|
{ width: 4 }, // B
|
||||||
s(SR + 1, 6, "Authorized Signatory & Stamp");
|
{ width: 22 }, // C
|
||||||
s(SR + 2, 0, "For, Pelagia Marine Services Pvt. Ltd.");
|
{ width: 10 }, // D
|
||||||
s(SR + 2, 5, "For");
|
{ width: 7 }, // E
|
||||||
s(SR + 2, 6, po.vendor?.name ?? "");
|
{ width: 16 }, // F
|
||||||
|
{ width: 7 }, // G
|
||||||
ws["!ref"] = XLSX.utils.encode_range({ r: 0, c: 0 }, { r: SR + 3, c: 8 });
|
{ width: 7 }, // H
|
||||||
ws["!cols"] = [
|
{ width: 16 }, // I
|
||||||
{ 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, "-"));
|
// ── Style constants ─────────────────────────────────────────────────────
|
||||||
const buf = XLSX.write(wb, { type: "buffer", bookType: "xlsx" });
|
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: {
|
headers: {
|
||||||
"Content-Type": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
"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) => `
|
const itemRows = items.map((item, i) => `
|
||||||
<tr style="background:${i % 2 === 0 ? "#fff" : "#fafafa"}">
|
<tr style="background:${i % 2 === 0 ? "#fff" : "#fafafa"}">
|
||||||
<td style="text-align:center">${item.sn}</td>
|
<td style="text-align:center">${item.sn}</td>
|
||||||
<td>${item.desc}</td>
|
<td>${item.desc}</td>
|
||||||
<td style="text-align:center">${item.unit}</td>
|
<td style="text-align:center">${item.unit}</td>
|
||||||
<td style="text-align:right">${fmt(item.qty, 3)}</td>
|
<td style="text-align:right">${fmtNum(item.qty, item.qty % 1 === 0 ? 0 : 3)}</td>
|
||||||
<td style="text-align:right">${fmt(item.unitPrice)}</td>
|
<td style="text-align:right">${fmtNum(item.unitPrice)}</td>
|
||||||
<td style="text-align:right">${fmt(item.taxable)}</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: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("");
|
</tr>`).join("");
|
||||||
|
|
||||||
const termLines = tcLines.map(([num, label, value]) =>
|
// Pad to at least 7 rows so the table doesn't collapse
|
||||||
`<li style="margin-bottom:3px"><b>${num}.</b> ${label ? `<b>${label}:</b> ` : ""}${value}</li>`
|
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("");
|
).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>
|
const html = `<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8"/>
|
<meta charset="UTF-8"/>
|
||||||
<title>${po.poNumber} — Purchase Order</title>
|
<title>${po.poNumber} — Purchase Order</title>
|
||||||
<style>
|
<style>
|
||||||
* { box-sizing: border-box; }
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
body { font-family: Arial, sans-serif; font-size: 9pt; margin: 12mm; color: #111; }
|
body {
|
||||||
h1 { font-size: 14pt; text-align: center; margin: 0 0 2px; }
|
font-family: Arial, Helvetica, sans-serif;
|
||||||
.center { text-align: center; }
|
font-size: 8.5pt;
|
||||||
.right { text-align: right; }
|
color: #111;
|
||||||
.bold { font-weight: bold; }
|
margin: 10mm 12mm;
|
||||||
table { width: 100%; border-collapse: collapse; margin-bottom: 4px; }
|
line-height: 1.3;
|
||||||
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; }
|
/* ── Header ── */
|
||||||
.section-header td { background: #d8d8d8; font-weight: bold; text-align: center; padding: 3px; }
|
.co-name {
|
||||||
.totals-row td { background: #f0f0f0; }
|
text-align: center;
|
||||||
.grand-total td { background: #d8d8d8; font-weight: bold; font-size: 9pt; }
|
font-size: 13pt;
|
||||||
.sig-block { margin-top: 12px; display: flex; justify-content: space-between; }
|
font-weight: bold;
|
||||||
.sig-box { border: 1px solid #999; padding: 8px 16px; min-height: 50px; width: 45%; text-align: center; }
|
border-bottom: 1px solid #555;
|
||||||
ol { margin: 2px 0 0 14px; padding: 0; }
|
padding-bottom: 3px;
|
||||||
li { margin-bottom: 2px; }
|
margin-bottom: 1px;
|
||||||
@media print { .no-print { display: none; } }
|
}
|
||||||
|
.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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<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>
|
</div>
|
||||||
|
|
||||||
<!-- Header -->
|
<!-- ── Header ─────────────────────────────────────────────────── -->
|
||||||
<h1>${COMPANY_NAME}</h1>
|
<div class="co-name">${CO_NAME}</div>
|
||||||
<p class="center" style="margin:0">${COMPANY_ADDR}</p>
|
<div class="co-addr">${CO_ADDR}</div>
|
||||||
<p class="center" style="margin:0 0 4px">${COMPANY_TEL}</p>
|
<div class="co-tel">${CO_TEL}</div>
|
||||||
<hr style="border-top:1.5px solid #333;margin:4px 0"/>
|
<div class="po-title">PURCHASE ORDER</div>
|
||||||
<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 -->
|
<!-- ── PO Meta & Quotation ──────────────────────────────────── -->
|
||||||
<table>
|
<table class="meta" style="margin-bottom:0">
|
||||||
<tr>
|
<tr>
|
||||||
<td class="bold" style="width:22%">Purchase Order No:</td>
|
<td class="lbl" style="width:22%">Purchase Order No:</td>
|
||||||
<td class="bold" style="width:24%">${po.poNumber}</td>
|
<td style="width:28%;font-weight:bold">${po.poNumber}</td>
|
||||||
<td class="bold" style="width:18%;text-align:right">Date:</td>
|
<td class="lbl" style="width:14%;text-align:right">Date:</td>
|
||||||
<td style="width:36%">${fmtDate(po.createdAt)}</td>
|
<td style="width:36%">${fmtDate(po.createdAt)}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td class="bold">PI / Quotation No:</td>
|
<td class="lbl">Performa Invoice / Quotation No:</td>
|
||||||
<td>${piNo}</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>
|
<td>${piDate}</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
<!-- Vessel / Req -->
|
<!-- ── Vessel / Budget / Requested By ───────────────────────── -->
|
||||||
<table>
|
<table class="meta" style="margin-bottom:0">
|
||||||
<tr>
|
<tr>
|
||||||
<td class="bold" style="width:22%">Vessel Owner Name</td>
|
<td class="lbl" style="width:22%">Vessel Owner Name</td>
|
||||||
<td style="width:20%">Pelagia Marine Services Pvt. Ltd.</td>
|
<td style="width:24%">Pelagia Marine Services Pvt. Ltd.</td>
|
||||||
<td class="bold" style="width:12%;text-align:center">Budget Head</td>
|
<td class="lbl" style="width:12%;text-align:center">Budget head</td>
|
||||||
<td style="width:10%;text-align:center">${po.account.code}</td>
|
<td style="width:8%;text-align:center">${po.account.code}</td>
|
||||||
<td class="bold" style="width:14%">Requested By</td>
|
<td class="lbl" style="width:14%">Requested By</td>
|
||||||
<td style="width:22%">${po.submitter.name}</td>
|
<td style="width:20%">${po.submitter.name}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td class="bold">Vessel/Office Req. No.</td>
|
<td class="lbl">Vessel/Office Requisition Number</td>
|
||||||
<td>${reqNo}</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 style="text-align:center">${reqDate}</td>
|
||||||
<td class="bold">Approved By</td>
|
<td class="lbl">Approved By</td>
|
||||||
<td>${approvedBy}</td>
|
<td>${approvedBy}</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
<!-- Delivery -->
|
<!-- ── Place of Delivery ─────────────────────────────────────── -->
|
||||||
<table>
|
<table class="meta" style="margin-bottom:0">
|
||||||
<tr>
|
<tr>
|
||||||
<td class="bold" style="width:22%">Place of Delivery</td>
|
<td class="lbl" style="width:22%">Place of Delivery</td>
|
||||||
<td>${placeOfDelivery}</td>
|
<td>${delivery}</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
<!-- Invoice details -->
|
<!-- ── Invoice Details ───────────────────────────────────────── -->
|
||||||
<table>
|
<table class="meta" style="margin-bottom:0">
|
||||||
<tr>
|
<tr>
|
||||||
<td class="bold" style="width:22%">Invoice Details</td>
|
<td class="lbl" style="width:22%;vertical-align:middle" rowspan="2">Invoice Details</td>
|
||||||
<td>${INVOICE_ADDR}<br/>${INVOICE_GST}</td>
|
<td style="border-bottom:none">${INV_ADDR}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="border-top:none;font-size:8pt">${INV_GST}</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
<!-- Vendor -->
|
<!-- ── Vendor ────────────────────────────────────────────────── -->
|
||||||
<table>
|
<table class="meta" style="margin-bottom:4px">
|
||||||
<tr>
|
<tr>
|
||||||
<td class="bold" style="width:22%">Vendor Name & Address</td>
|
<td class="lbl" style="width:22%">Vendor Name & Address</td>
|
||||||
<td style="width:22%">${po.vendor?.name ?? "—"}</td>
|
<td style="width:24%;font-weight:bold">${po.vendor?.name ?? "—"}</td>
|
||||||
<td>${[po.vendor?.address, po.vendor?.gstin ? `GSTIN: ${po.vendor.gstin}` : ""].filter(Boolean).join(" ")}</td>
|
<td colspan="2">${vendorAddr}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td class="bold">Contact Person / Mobile</td>
|
<td class="lbl">Contact Person name / mobile no.</td>
|
||||||
<td colspan="2">${[po.vendor?.contactName, po.vendor?.contactMobile, po.vendor?.contactEmail].filter(Boolean).join(" ")}</td>
|
<td colspan="3">${vendorContact}</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
<!-- Line Items -->
|
<!-- ── Line Items ────────────────────────────────────────────── -->
|
||||||
<table>
|
<table class="items">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th style="width:4%">S.N.</th>
|
<th style="width:4%">S.N.</th>
|
||||||
<th style="width:30%">Description</th>
|
<th style="width:30%">Description</th>
|
||||||
<th style="width:7%">Unit</th>
|
<th style="width:6%;text-align:center">Unit</th>
|
||||||
<th style="width:7%;text-align:right">Qty</th>
|
<th style="width:7%;text-align:right">Qnty</th>
|
||||||
<th style="width:10%;text-align:right">Unit Price</th>
|
<th style="width:11%;text-align:right">Unit price</th>
|
||||||
<th style="width:14%;text-align:right">Taxable Cost</th>
|
<th style="width:14%;text-align:right">Taxable cost</th>
|
||||||
<th style="width:7%;text-align:center">GST%</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>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
${itemRows}
|
${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("")}
|
${padRows}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
<!-- Totals -->
|
<!-- ── Totals ────────────────────────────────────────────────── -->
|
||||||
<table style="width:50%;margin-left:auto">
|
<table class="totals">
|
||||||
<tr class="totals-row">
|
<tr>
|
||||||
<td class="bold" style="text-align:right">Total Taxable Value</td>
|
<td class="tot-lbl">Total taxable value</td>
|
||||||
<td style="text-align:right;width:35%">${fmt(totalTaxable)}</td>
|
<td class="tot-val">${fmtNum(totalTaxable)}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr class="totals-row">
|
<tr>
|
||||||
<td class="bold" style="text-align:right">GST (${Math.round((items[0]?.gstRate ?? 0.18) * 100)}%)</td>
|
<td class="tot-lbl">${gstLabel}</td>
|
||||||
<td style="text-align:right">${fmt(totalGst)}</td>
|
<td class="tot-val">${fmtNum(totalGst)}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr class="grand-total">
|
<tr>
|
||||||
<td style="text-align:right">GRAND TOTAL (INR)</td>
|
<td class="gt-lbl">GRAND TOTAL</td>
|
||||||
<td style="text-align:right">${fmt(grandTotal)}</td>
|
<td class="gt-val">${fmtNum(grandTotal)}</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
<!-- T&C -->
|
<div class="spacer"></div>
|
||||||
<table style="margin-top:6px">
|
|
||||||
<tr class="section-header"><td>INSTRUCTIONS TO VENDORS</td></tr>
|
<!-- ── Instructions to Vendors ───────────────────────────────── -->
|
||||||
<tr><td><ul style="list-style:none;padding:0;margin:0">${termLines}</ul></td></tr>
|
<table style="margin-bottom:6px">
|
||||||
|
<tr class="inst-hdr"><td colspan="2">INSTRUCTIONS TO VENDORS</td></tr>
|
||||||
|
${termRows}
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
<!-- Signature -->
|
<!-- ── Signatures ────────────────────────────────────────────── -->
|
||||||
<div class="sig-block">
|
<div class="sig">
|
||||||
<div class="sig-box">
|
<div class="sig-box">
|
||||||
<p style="margin:0 0 24px;font-weight:bold">${po.submitter.name}</p>
|
<div class="sig-name">${po.submitter.name}</div>
|
||||||
<p style="margin:0;font-size:8pt">Authorized Signatory & Stamp</p>
|
<div>
|
||||||
<p style="margin:0;font-size:8pt">For, Pelagia Marine Services Pvt. Ltd.</p>
|
<div class="sig-sub">Authorized Signatory & Stamp</div>
|
||||||
|
<div class="sig-sub">For, Pelagia Marine Services Pvt. Ltd.</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="sig-box">
|
<div class="sig-box">
|
||||||
<p style="margin:0 0 24px;font-weight:bold">${po.vendor?.name ?? ""}</p>
|
<div class="sig-name">${po.vendor?.name ?? ""}</div>
|
||||||
<p style="margin:0;font-size:8pt">Authorized Signatory & Stamp</p>
|
<div>
|
||||||
<p style="margin:0;font-size:8pt">For, ${po.vendor?.name ?? ""}</p>
|
<div class="sig-sub">Authorized Signatory & Stamp</div>
|
||||||
|
<div class="sig-sub">For, ${po.vendor?.name ?? ""}</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -385,7 +653,5 @@ export async function GET(request: NextRequest, { params }: Props) {
|
||||||
</body>
|
</body>
|
||||||
</html>`;
|
</html>`;
|
||||||
|
|
||||||
return new NextResponse(html, {
|
return new NextResponse(html, { headers: { "Content-Type": "text/html; charset=utf-8" } });
|
||||||
headers: { "Content-Type": "text/html; charset=utf-8" },
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,7 @@
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
|
"exceljs": "^4.4.0",
|
||||||
"lucide-react": "^0.468.0",
|
"lucide-react": "^0.468.0",
|
||||||
"next": "^15.1.0",
|
"next": "^15.1.0",
|
||||||
"next-auth": "^5.0.0-beta.25",
|
"next-auth": "^5.0.0-beta.25",
|
||||||
|
|
|
||||||
502
App/pelagia-portal/pnpm-lock.yaml
generated
502
App/pelagia-portal/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
Loading…
Add table
Reference in a new issue