GWM : pdf desigh for pd and grn

This commit is contained in:
Gowtham M 2026-07-14 09:48:59 +05:30
parent 82676316c6
commit 969647333f
14 changed files with 1234 additions and 507 deletions

View File

@ -309,7 +309,7 @@ Each sub-master supports: `GET /` (list), `GET /:id`, `POST /`, `PUT /:id`, `DEL
| Status | Method | Endpoint | RBAC | Notes |
|--------|--------|----------|------|-------|
| [x] | GET | `/settings/company` | view | Company profile (org name, contact, address, logo URL) |
| [x] | GET | `/settings/company` | view | Company profile (org name, GSTIN, contact, address, logo URL) |
| [x] | PUT | `/settings/company` | edit | Update company profile |
| [x] | POST | `/settings/company/logo` | edit | Upload logo (`multipart/form-data`, field `logo`) |
| [x] | GET | `/settings/email` | view | SMTP settings (`has_smtp_password` flag; password never returned) |
@ -317,6 +317,8 @@ Each sub-master supports: `GET /` (list), `GET /:id`, `POST /`, `PUT /:id`, `DEL
**DB patch:** `scripts/patch-company-email-settings.sql` — creates `company` + `email_settings` singleton tables and `SETTINGS` module permissions for Super Admin.
**DB patch:** `scripts/patch-company-gstin.sql` — adds `company.gstin` for PDF document headers.
---
### Audit Logs (`/audit-logs`) — module: `AUDIT_LOGS`

View File

@ -1004,6 +1004,7 @@ model vendors {
model company {
id BigInt @id @default(1)
org_name String? @db.VarChar(200)
gstin String? @db.VarChar(15)
mobile String? @db.VarChar(20)
email String? @db.VarChar(200)
website String? @db.VarChar(255)

View File

@ -3,6 +3,7 @@
CREATE TABLE IF NOT EXISTS company (
id BIGINT PRIMARY KEY DEFAULT 1 CHECK (id = 1),
org_name VARCHAR(200),
gstin VARCHAR(15),
mobile VARCHAR(20),
email VARCHAR(200),
website VARCHAR(255),
@ -17,6 +18,9 @@ CREATE TABLE IF NOT EXISTS company (
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
ALTER TABLE company
ADD COLUMN IF NOT EXISTS gstin VARCHAR(15);
CREATE TABLE IF NOT EXISTS email_settings (
id BIGINT PRIMARY KEY DEFAULT 1 CHECK (id = 1),
smtp_host VARCHAR(255),

View File

@ -0,0 +1,4 @@
-- Add GSTIN to company profile (used on PO/GRN PDF headers)
ALTER TABLE company
ADD COLUMN IF NOT EXISTS gstin VARCHAR(15);

View File

@ -8,6 +8,7 @@ components:
minProperties: 1
properties:
org_name: { type: string, example: 'Bharat Consumer Products' }
gstin: { type: string, example: '33AABCB1234D1Z5', description: '15-character GSTIN' }
mobile: { type: string, example: '9876543210' }
email: { type: string, format: email, example: 'info@company.com' }
website: { type: string, example: 'https://www.company.com' }

View File

@ -3,7 +3,10 @@ const ApiError = require('../../utils/ApiError');
const auditLog = require('../../utils/auditLog');
const { getPagination } = require('../../utils/pagination');
const { nextDocumentNumber } = require('../../utils/generateCode');
const { buildSimplePdf } = require('../../utils/simplePdf');
const { generatePdf } = require('../../utils/pdf/pdfGenerator');
const { generateGrnHtml } = require('../../utils/pdf/templates/grn.template');
const { formatDate } = require('../../utils/pdf/helpers/formatDate');
const { getCompanyForDocuments } = require('../settings/settings.service');
const repository = require('./grn.repository');
const { assertWarehouse } = require('../../utils/locations');
const { sanitizeAttachment } = require('./grn.attachments.service');
@ -39,6 +42,43 @@ const grnDetailInclude = {
},
};
const grnPdfInclude = {
...grnDetailInclude,
vendors: {
select: {
id: true,
vendor_code: true,
vendor_name: true,
gstin: true,
vendor_addresses: {
where: { is_active: true },
orderBy: { id: 'asc' },
take: 5,
select: {
address_type: true,
address_line1: true,
address_line2: true,
city: true,
state: true,
pincode: true,
gstin: true,
},
},
},
},
warehouse: {
select: {
id: true,
code: true,
name: true,
address: true,
city: true,
state: true,
pincode: true,
},
},
};
const toDateOnly = (value) => {
if (!value) return null;
const date = new Date(value);
@ -97,15 +137,113 @@ const sanitizeGrn = (row) => {
};
};
const getGrnOrThrow = async (id, { includeItems = false } = {}) => {
const getGrnOrThrow = async (id, { includeItems = false, forPdf = false } = {}) => {
const include = forPdf ? grnPdfInclude : includeItems ? grnDetailInclude : grnListInclude;
const row = await prisma.grn.findFirst({
where: { id: BigInt(id), deleted_at: null },
include: includeItems ? grnDetailInclude : grnListInclude,
include,
});
if (!row) throw new ApiError(404, 'GRN not found');
return row;
};
const humanizeLabel = (value) => {
if (!value) return '-';
return String(value)
.toLowerCase()
.split('_')
.filter(Boolean)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(' ');
};
const pickVendorAddress = (addresses = []) => {
if (!addresses.length) return null;
return (
addresses.find((row) => String(row.address_type || '').toUpperCase() === 'BILLING') ||
addresses.find((row) => String(row.address_type || '').toUpperCase() === 'SHIPPING') ||
addresses[0]
);
};
const buildVendorAddressLine = (address) => {
if (!address) return '';
return [address.address_line1, address.address_line2].filter(Boolean).join(', ');
};
const buildGrnPdfPayload = (grn, company) => {
const vendorAddress = pickVendorAddress(grn.vendor?.vendor_addresses || []);
const items = (grn.items || []).map((line) => {
const acceptedQty = toNum(line.accepted_qty);
const rejectedQty = toNum(line.rejected_qty);
const rate = toNum(line.rate);
return {
line_no: line.line_no,
item_name: line.item?.item_name || '-',
item_code: line.item?.item_code || null,
remarks: line.remarks || line.rejection_reason || null,
uom: line.uom?.code || line.uom?.name || '-',
ordered_qty: toNum(line.ordered_qty),
current_qty: toNum(line.current_qty),
accepted_qty: acceptedQty,
rejected_qty: rejectedQty,
rate,
line_total: acceptedQty * rate,
};
});
const acceptedValue = items.reduce((sum, line) => sum + toNum(line.line_total), 0);
const rejectedValue = items.reduce(
(sum, line) => sum + toNum(line.rejected_qty) * toNum(line.rate),
0
);
return {
company: {
...company,
gstin: company.gstin || '',
},
grn: {
grn_number: grn.grn_number,
grn_date: grn.grn_date,
status: humanizeLabel(grn.status),
po_number: grn.purchase_order?.po_number || '-',
vendor_invoice_no: grn.vendor_invoice_no || null,
vendor_invoice_date: grn.vendor_invoice_date || null,
vendor_invoice_amount:
grn.vendor_invoice_amount != null ? toNum(grn.vendor_invoice_amount) : null,
vehicle_no: grn.vehicle_no || null,
lr_no: grn.lr_no || null,
lr_date: grn.lr_date || null,
received_by: grn.received_by_user?.full_name || '-',
quality_checked_by: grn.quality_checked_by_user?.full_name || '-',
remarks: grn.remarks || null,
},
vendor: {
vendor_name: grn.vendor?.vendor_name || '-',
gstin: grn.vendor?.gstin || vendorAddress?.gstin || '',
address: buildVendorAddressLine(vendorAddress),
city: vendorAddress?.city || '',
state: vendorAddress?.state || '',
pincode: vendorAddress?.pincode || '',
},
warehouse: {
name: grn.warehouse?.name || '-',
address: grn.warehouse?.address || '',
city: grn.warehouse?.city || '',
state: grn.warehouse?.state || '',
pincode: grn.warehouse?.pincode || '',
},
items,
totals: {
accepted_value: acceptedValue,
rejected_value: rejectedValue,
grand_total: acceptedValue,
},
generated_at: `${formatDate(new Date(), { style: 'datetime' })} IST`,
};
};
const getReceivablePoOrThrow = async (poId) => {
const po = await prisma.purchase_orders.findFirst({
where: { id: BigInt(poId), deleted_at: null },
@ -430,25 +568,14 @@ const cancelGrn = async (id, payload, userId, requestId) => {
};
const getGrnPdf = async (id) => {
const grn = sanitizeGrn(await getGrnOrThrow(id, { includeItems: true }));
const lines = [
`GRN: ${grn.grn_number}`,
`Date: ${grn.grn_date ? new Date(grn.grn_date).toISOString().slice(0, 10) : '-'}`,
`Status: ${grn.status}`,
`PO: ${grn.purchase_order?.po_number || '-'}`,
`Vendor: ${grn.vendor?.vendor_name || '-'}`,
`Warehouse: ${grn.warehouse?.name || '-'}`,
'',
'Line Items:',
...grn.items.map(
(line) =>
`${line.line_no}. ${line.item?.item_name || line.item_id} | Accepted ${line.accepted_qty} / Current ${line.current_qty}`
),
];
const grn = sanitizeGrn(await getGrnOrThrow(id, { forPdf: true }));
const company = await getCompanyForDocuments();
const html = generateGrnHtml(buildGrnPdfPayload(grn, company));
const buffer = await generatePdf(html);
return {
filename: `${grn.grn_number.replace(/\//g, '-')}.pdf`,
buffer: buildSimplePdf(lines),
buffer,
};
};

View File

@ -3,7 +3,10 @@ const ApiError = require('../../utils/ApiError');
const auditLog = require('../../utils/auditLog');
const { getPagination } = require('../../utils/pagination');
const { nextDocumentNumber } = require('../../utils/generateCode');
const { buildSimplePdf } = require('../../utils/simplePdf');
const { generatePdf } = require('../../utils/pdf/pdfGenerator');
const { generatePoHtml } = require('../../utils/pdf/templates/po.template');
const { formatDate } = require('../../utils/pdf/helpers/formatDate');
const { getCompanyForDocuments } = require('../settings/settings.service');
const {
EDITABLE_STATUSES,
SUBMITTABLE_STATUSES,
@ -48,6 +51,43 @@ const poDetailInclude = {
},
};
const poPdfInclude = {
...poDetailInclude,
vendors: {
select: {
id: true,
vendor_code: true,
vendor_name: true,
gstin: true,
vendor_addresses: {
where: { is_active: true },
orderBy: { id: 'asc' },
take: 5,
select: {
address_type: true,
address_line1: true,
address_line2: true,
city: true,
state: true,
pincode: true,
gstin: true,
},
},
},
},
plant: {
select: {
id: true,
code: true,
name: true,
address: true,
city: true,
state: true,
pincode: true,
},
},
};
const toDateOnly = (value) => {
if (!value) return null;
const date = new Date(value);
@ -251,15 +291,100 @@ const buildHeaderData = async (payload, builtItems, userId) => {
};
};
const getPoOrThrow = async (id, { includeItems = false } = {}) => {
const getPoOrThrow = async (id, { includeItems = false, forPdf = false } = {}) => {
const include = forPdf ? poPdfInclude : includeItems ? poDetailInclude : poListInclude;
const po = await prisma.purchase_orders.findFirst({
where: { id: BigInt(id), deleted_at: null },
include: includeItems ? poDetailInclude : poListInclude,
include,
});
if (!po) throw new ApiError(404, 'Purchase order not found');
return po;
};
const humanizeLabel = (value) => {
if (!value) return '-';
return String(value)
.toLowerCase()
.split('_')
.filter(Boolean)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(' ');
};
const pickVendorAddress = (addresses = []) => {
if (!addresses.length) return null;
const preferred =
addresses.find((row) => String(row.address_type || '').toUpperCase() === 'BILLING') ||
addresses.find((row) => String(row.address_type || '').toUpperCase() === 'SHIPPING') ||
addresses[0];
return preferred;
};
const buildVendorAddressLine = (address) => {
if (!address) return '';
return [address.address_line1, address.address_line2].filter(Boolean).join(', ');
};
const buildPoPdfPayload = (po, company) => {
const vendorAddress = pickVendorAddress(po.vendor?.vendor_addresses || []);
return {
company: {
...company,
gstin: company.gstin || '',
},
po: {
po_number: po.po_number,
po_date: po.po_date,
expected_delivery_date: po.expected_delivery_date,
status: humanizeLabel(po.status),
po_type: humanizeLabel(po.po_type),
revision_no: po.revision_no ?? 0,
payment_term: po.payment_term?.name || null,
delivery_term: po.delivery_term?.name || null,
terms_and_conditions: po.terms_and_conditions || null,
remarks: po.remarks || null,
},
vendor: {
vendor_name: po.vendor?.vendor_name || '-',
gstin: po.vendor?.gstin || vendorAddress?.gstin || '',
address: buildVendorAddressLine(vendorAddress),
city: vendorAddress?.city || '',
state: vendorAddress?.state || '',
pincode: vendorAddress?.pincode || '',
},
ship_to: {
name: po.plant?.name || '-',
address: po.plant?.address || '',
city: po.plant?.city || '',
state: po.plant?.state || '',
pincode: po.plant?.pincode || '',
},
brand: { name: po.brand?.name || '-' },
warehouse: { name: po.warehouse?.name || '-' },
items: (po.items || []).map((line) => ({
line_no: line.line_no,
item_name: line.item?.item_name || '-',
remarks: line.remarks || null,
uom: line.uom?.code || line.uom?.name || '-',
ordered_qty: toNum(line.ordered_qty),
rate: toNum(line.rate),
discount_pct: toNum(line.discount_pct),
gst_rate_pct: toNum(line.gst_rate?.rate_pct),
line_total: toNum(line.line_total),
})),
totals: {
sub_total: toNum(po.sub_total),
tax_total: toNum(po.tax_total),
freight_charges: toNum(po.freight_charges),
other_charges: toNum(po.other_charges),
discount_amount: toNum(po.discount_amount),
grand_total: toNum(po.grand_total),
},
generated_at: `${formatDate(new Date(), { style: 'datetime' })} IST`,
};
};
const hasReceipts = (po) =>
(po.purchase_order_items || []).some((line) => Number(line.received_qty) > 0);
@ -674,25 +799,14 @@ const amendPurchaseOrder = async (id, payload, userId, requestId) => {
};
const getPurchaseOrderPdf = async (id) => {
const po = sanitizePo(await getPoOrThrow(id, { includeItems: true }));
const lines = [
`Purchase Order: ${po.po_number}`,
`Date: ${po.po_date ? new Date(po.po_date).toISOString().slice(0, 10) : '-'}`,
`Status: ${po.status}`,
`Vendor: ${po.vendor?.vendor_name || '-'}`,
`Plant: ${po.plant?.name || '-'}`,
`Grand Total: ${po.grand_total}`,
'',
'Line Items:',
...po.items.map(
(line) =>
`${line.line_no}. ${line.item?.item_name || line.item_id} | Qty ${line.ordered_qty} @ ${line.rate} = ${line.line_total}`
),
];
const po = sanitizePo(await getPoOrThrow(id, { forPdf: true }));
const company = await getCompanyForDocuments();
const html = generatePoHtml(buildPoPdfPayload(po, company));
const buffer = await generatePdf(html);
return {
filename: `${po.po_number.replace(/\//g, '-')}.pdf`,
buffer: buildSimplePdf(lines),
buffer,
};
};

View File

@ -20,6 +20,7 @@ const sanitizeCompany = (row) => {
return {
id: row.id,
org_name: row.org_name,
gstin: row.gstin,
mobile: row.mobile,
email: row.email,
website: row.website,
@ -72,6 +73,7 @@ const updateCompany = async (payload, userId, requestId) => {
const data = {
...(payload.org_name !== undefined ? { org_name: payload.org_name || null } : {}),
...(payload.gstin !== undefined ? { gstin: payload.gstin || null } : {}),
...(payload.mobile !== undefined ? { mobile: payload.mobile || null } : {}),
...(payload.email !== undefined ? { email: payload.email || null } : {}),
...(payload.website !== undefined ? { website: payload.website || null } : {}),
@ -179,7 +181,7 @@ const getCompanyForDocuments = async () => {
city: row.city || '',
state: row.state || '',
pincode: row.pincode || '',
gstin: '',
gstin: row.gstin || '',
phone: row.mobile || '',
email: row.email || '',
website: row.website || '',

View File

@ -3,6 +3,7 @@ const { masterName } = require('../masters/_shared/masters.validation');
const companyUpdateSchema = Joi.object({
org_name: masterName({ max: 200 }),
gstin: Joi.string().length(15).allow(null, '').optional(),
mobile: Joi.string().max(20).allow(null, '').optional(),
email: Joi.string().email().max(200).allow(null, '').optional(),
website: Joi.string().max(255).allow(null, '').optional(),

View File

@ -1,16 +1,45 @@
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
const formatDate = (value) => {
if (!value) return '-';
const pad2 = (value) => String(value).padStart(2, '0');
const date = new Date(value);
if (Number.isNaN(date.getTime())) return '-';
const toValidDate = (value) => {
if (!value) return null;
const date = value instanceof Date ? value : new Date(value);
if (Number.isNaN(date.getTime())) return null;
return date;
};
const day = String(date.getDate()).padStart(2, '0');
const month = MONTHS[date.getMonth()];
const year = date.getFullYear();
const isDateOnlyString = (value) => typeof value === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(value);
return `${day}-${month}-${year}`;
/**
* Prefer UTC for calendar dates (Prisma `@db.Date` / YYYY-MM-DD) so timezone
* shifts do not move the day. Use local time for datetime display.
* @param {Date|string|number|null|undefined} value
* @param {{ style?: 'short' | 'numeric' | 'datetime' }} [options]
* @returns {string}
*/
const formatDate = (value, options = {}) => {
const date = toValidDate(value);
if (!date) return '-';
const style = options.style || 'short';
const useUtc = style !== 'datetime' && (isDateOnlyString(value) || style === 'numeric' || style === 'short');
const day = pad2(useUtc ? date.getUTCDate() : date.getDate());
const monthIdx = useUtc ? date.getUTCMonth() : date.getMonth();
const year = useUtc ? date.getUTCFullYear() : date.getFullYear();
if (style === 'numeric') {
return `${day}/${pad2(monthIdx + 1)}/${year}`;
}
if (style === 'datetime') {
const hours = pad2(date.getHours());
const minutes = pad2(date.getMinutes());
return `${day}/${pad2(monthIdx + 1)}/${year} ${hours}:${minutes}`;
}
return `${day}-${MONTHS[monthIdx]}-${year}`;
};
module.exports = { formatDate };

View File

@ -67,10 +67,10 @@ const numberToWords = (value = 0) => {
const paiseWords = decimalPart ? twoDigitsToWords(decimalPart) : '';
if (decimalPart) {
return `${rupeesWords} Rupees and ${paiseWords} Paise Only`;
return `Rupees ${rupeesWords} and ${paiseWords} Paise Only`;
}
return `${rupeesWords} Rupees Only`;
return `Rupees ${rupeesWords} Only`;
};
module.exports = { numberToWords };

View File

@ -1,8 +1,23 @@
const fs = require('fs');
const CANDIDATE_CHROME_PATHS = [
process.env.PUPPETEER_EXECUTABLE_PATH,
'/usr/bin/google-chrome',
'/usr/bin/google-chrome-stable',
'/usr/bin/chromium-browser',
'/usr/bin/chromium',
].filter(Boolean);
const resolveChromePath = () => CANDIDATE_CHROME_PATHS.find((path) => fs.existsSync(path));
const generatePdf = async (htmlContent) => {
const puppeteer = await import('puppeteer');
const executablePath = resolveChromePath();
const browser = await puppeteer.default.launch({
headless: true,
args: ['--no-sandbox', '--disable-setuid-sandbox'],
...(executablePath ? { executablePath } : {}),
args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage'],
});
try {
@ -12,10 +27,10 @@ const generatePdf = async (htmlContent) => {
const pdfBuffer = await page.pdf({
format: 'A4',
printBackground: true,
margin: { top: '16mm', right: '14mm', bottom: '16mm', left: '14mm' },
margin: { top: '14mm', right: '14mm', bottom: '14mm', left: '14mm' },
});
return pdfBuffer;
return Buffer.from(pdfBuffer);
} finally {
await browser.close();
}

View File

@ -1,231 +1,531 @@
const { formatCurrency } = require('../helpers/formatCurrency');
const { formatDate } = require('../helpers/formatDate');
const { numberToWords } = require('../helpers/numberToWords');
const {
escapeHtml,
getBaseStyles,
renderCompanyHeader,
renderPartyCard,
renderFootnote,
getDummyCompany,
} = require('../helpers/templateBase');
const getDummyGrnData = () => ({
company: getDummyCompany(),
company: {
name: 'Bharat Industries Pvt. Ltd.',
address: 'Plot 14, Guindy Industrial Estate',
city: 'Chennai',
state: 'Tamil Nadu',
pincode: '600032',
gstin: '33AABCB1234D1Z5',
phone: '+91 44 4000 1200',
email: 'procurement@bharatindustries.in',
},
grn: {
grn_number: 'GRN-2026-00018',
grn_date: '2026-06-22',
status: 'POSTED',
po_number: 'PO-2026-00047',
vendor_invoice_no: 'SIS/INV/2026/441',
vendor_invoice_date: '2026-06-21',
vendor_invoice_amount: 254743.5,
vehicle_no: 'MH-12-AB-4521',
grn_number: 'GRN/2026-27/00012',
grn_date: '2026-07-14',
status: 'Posted',
po_number: 'PO/2026-27/00007',
vendor_invoice_no: 'TSL/INV/2026/441',
vendor_invoice_date: '2026-07-13',
vendor_invoice_amount: 139943.75,
vehicle_no: 'TN-09-AB-4521',
lr_no: 'LR-77821',
lr_date: '2026-06-21',
lr_date: '2026-07-13',
received_by: 'Amit Sharma',
quality_checked_by: 'Priya Nair',
remarks: 'All items inspected and accepted except partial rejection on line 3.',
},
vendor: {
vendor_name: 'Shree Industrial Supplies',
gstin: '27AAECS7788B1Z2',
address: 'Plot 17, MIDC Industrial Area',
city: 'Pune',
state: 'Maharashtra',
pincode: '411019',
contact_name: 'Rajesh Patil',
phone: '+91 98220 12345',
vendor_name: 'Tata Steel Limited',
gstin: '20AABCT3456A1Z9',
address: 'Bistupur Main Road',
city: 'Jamshedpur',
state: 'Jharkhand',
pincode: '831001',
},
warehouse: {
code: 'WH-NOI-01',
name: 'Noida Main Warehouse',
address: 'Plot 9, Industrial Estate, Sector 63, Noida',
name: 'Main warehouse',
address: 'Guindy Industrial Estate',
city: 'Chennai',
state: 'Tamil Nadu',
pincode: '600032',
},
items: [
{
line_no: 1,
item_code: 'ITM-MS-CH-100',
item_name: 'MS Channel 100x50 mm',
uom: 'KG',
ordered_qty: 1250,
previously_received_qty: 0,
current_qty: 1250,
accepted_qty: 1250,
item_name: 'TMT steel bars 12mm',
item_code: 'ITM-TMT-12',
uom: 'MT',
ordered_qty: 25,
current_qty: 25,
accepted_qty: 25,
rejected_qty: 0,
rate: 72.5,
line_total: 90625,
rate: 4250,
line_total: 106250,
},
{
line_no: 2,
item_code: 'ITM-FST-M12',
item_name: 'Industrial Fasteners Set M12',
uom: 'BOX',
ordered_qty: 40,
previously_received_qty: 0,
current_qty: 40,
accepted_qty: 40,
item_name: 'Cement OPC 53 grade',
item_code: 'ITM-CEM-53',
uom: 'Bag',
ordered_qty: 500,
current_qty: 500,
accepted_qty: 500,
rejected_qty: 0,
rate: 1425,
line_total: 57000,
rate: 380,
line_total: 190000,
},
{
line_no: 3,
item_code: 'ITM-WLD-E6013',
item_name: 'Welding Rod E6013',
uom: 'BOX',
ordered_qty: 30,
previously_received_qty: 0,
current_qty: 30,
accepted_qty: 28,
rejected_qty: 2,
rate: 985,
line_total: 27580,
},
{
line_no: 4,
item_code: 'ITM-SFT-GLV',
item_name: 'Safety Gloves (Heat Resistant)',
uom: 'PAIR',
ordered_qty: 200,
previously_received_qty: 0,
current_qty: 200,
accepted_qty: 200,
rejected_qty: 0,
rate: 210,
line_total: 42000,
item_name: 'River sand',
item_code: 'ITM-SND-01',
remarks: '2 bags damaged in transit',
uom: 'CFT',
ordered_qty: 1200,
current_qty: 1200,
accepted_qty: 1180,
rejected_qty: 20,
rate: 45,
line_total: 53100,
},
],
totals: {
sub_total: 217205,
grand_total: 217205,
accepted_value: 349350,
rejected_value: 900,
grand_total: 349350,
},
generated_at: '14/07/2026 09:40',
});
const escapeHtml = (value = '') =>
String(value ?? '')
.replace(/&/g, '&')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
const joinParts = (...parts) => parts.map((p) => String(p || '').trim()).filter(Boolean).join(', ');
const companyAddressLine = (company = {}) =>
joinParts(company.address, company.city, company.state, company.pincode);
const companyContactLine = (company = {}) => {
const parts = [];
if (company.gstin) parts.push(`GSTIN: ${company.gstin}`);
if (company.email) parts.push(company.email);
if (company.phone) parts.push(company.phone);
return parts.join(' · ');
};
const qtyDisplay = (value) => {
const num = Number(value || 0);
return Number.isInteger(num) ? String(num) : num.toFixed(2);
};
const generateGrnHtml = (inputData = getDummyGrnData()) => {
const data = inputData || getDummyGrnData();
const company = data.company || {};
const grn = data.grn || {};
const vendor = data.vendor || {};
const warehouse = data.warehouse || {};
const totals = data.totals || {};
const itemsRows = (data.items || [])
.map(
(item, index) => `
.map((item, index) => {
const code = item.item_code
? `<div class="item-code">${escapeHtml(item.item_code)}</div>`
: '';
const remarks = item.remarks
? `<div class="item-note">*${escapeHtml(item.remarks)}</div>`
: '';
return `
<tr>
<td>${item.line_no || index + 1}</td>
<td class="item-name">
${escapeHtml(item.item_name)}<br />
<span style="color:#6b7280;font-size:10px;">${escapeHtml(item.item_code)}</span>
<td class="col-no">${item.line_no || index + 1}</td>
<td class="col-item">
<div class="item-name">${escapeHtml(item.item_name || '-')}</div>
${code}
${remarks}
</td>
<td>${escapeHtml(item.uom)}</td>
<td class="text-right">${Number(item.ordered_qty || 0).toFixed(2)}</td>
<td class="text-right">${Number(item.accepted_qty || 0).toFixed(2)}</td>
<td class="text-right">${Number(item.rejected_qty || 0).toFixed(2)}</td>
<td class="text-right">${formatCurrency(item.rate)}</td>
<td class="text-right">${formatCurrency(item.line_total)}</td>
<td class="col-qty text-right">${qtyDisplay(item.ordered_qty)}</td>
<td class="col-qty text-right">${qtyDisplay(item.current_qty)}</td>
<td class="col-qty text-right">${qtyDisplay(item.accepted_qty)}</td>
<td class="col-qty text-right">${qtyDisplay(item.rejected_qty)}</td>
<td class="col-uom">${escapeHtml(item.uom || '-')}</td>
<td class="col-rate text-right">${formatCurrency(item.rate)}</td>
<td class="col-amt text-right">${formatCurrency(item.line_total)}</td>
</tr>
`
)
`;
})
.join('');
const docMetaHtml = `
<div><strong>GRN No:</strong> ${escapeHtml(data.grn.grn_number)}</div>
<div><strong>Date:</strong> ${formatDate(data.grn.grn_date)}</div>
<div><strong>PO Ref:</strong> ${escapeHtml(data.grn.po_number)}</div>
<div style="margin-top: 4px;"><span class="status">${escapeHtml(data.grn.status)}</span></div>
`;
const vendorAddress = joinParts(vendor.address, vendor.city, vendor.state, vendor.pincode);
const warehouseAddress = joinParts(
warehouse.address,
warehouse.city,
warehouse.state,
warehouse.pincode
);
const generatedAt = data.generated_at || formatDate(new Date(), { style: 'datetime' });
const lrLine = [grn.lr_no, grn.lr_date ? formatDate(grn.lr_date, { style: 'numeric' }) : null]
.filter(Boolean)
.join(' / ');
return `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Goods Receipt Note</title>
<style>${getBaseStyles()}</style>
<title>Goods Receipt Note ${escapeHtml(grn.grn_number || '')}</title>
<style>
* { box-sizing: border-box; }
body {
margin: 0;
font-family: Helvetica, Arial, sans-serif;
color: #111111;
background: #ffffff;
font-size: 11px;
line-height: 1.45;
}
.document { width: 100%; padding: 0; }
.label {
color: #9a9a9a;
font-size: 9px;
letter-spacing: 0.08em;
text-transform: uppercase;
font-weight: 600;
}
.value { color: #111111; font-weight: 700; }
.text-right { text-align: right; }
.divider {
border: 0;
border-top: 1.5px solid #111111;
margin: 14px 0;
}
.divider-light {
border: 0;
border-top: 1px solid #d8d8d8;
margin: 14px 0;
}
.header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 24px;
}
.company-name {
margin: 0;
font-size: 20px;
font-weight: 800;
letter-spacing: -0.02em;
}
.company-meta {
margin-top: 4px;
color: #555555;
font-size: 10.5px;
line-height: 1.5;
}
.title-block { text-align: right; min-width: 230px; }
.doc-title {
margin: 0;
font-size: 24px;
font-weight: 800;
letter-spacing: -0.02em;
}
.doc-number {
margin-top: 2px;
color: #555555;
font-size: 12px;
}
.meta-row {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 16px;
margin-top: 4px;
}
.meta-row .value { margin-top: 2px; font-size: 12px; }
.party-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 28px;
margin-top: 4px;
}
.party-name {
margin-top: 4px;
font-size: 13px;
font-weight: 800;
}
.party-body {
margin-top: 2px;
color: #444444;
font-size: 10.5px;
line-height: 1.5;
}
.class-row {
display: grid;
grid-template-columns: 1fr 1fr 1fr 1fr;
gap: 16px;
margin-top: 4px;
}
.class-row .value { margin-top: 2px; font-size: 12px; }
table.items {
width: 100%;
border-collapse: collapse;
margin-top: 2px;
}
table.items thead th {
padding: 8px 3px;
border-bottom: 1.5px solid #111111;
color: #9a9a9a;
font-size: 8.5px;
letter-spacing: 0.06em;
text-transform: uppercase;
font-weight: 600;
text-align: left;
}
table.items tbody td {
padding: 9px 3px;
border-bottom: 1px solid #e5e5e5;
vertical-align: top;
font-size: 10.5px;
}
.col-no { width: 4%; color: #666666; }
.col-item { width: 28%; }
.col-qty { width: 8%; }
.col-uom { width: 6%; }
.col-rate { width: 11%; }
.col-amt { width: 13%; }
.item-name { font-weight: 600; }
.item-code {
margin-top: 1px;
color: #888888;
font-size: 9px;
}
.item-note {
margin-top: 2px;
color: #888888;
font-size: 9.5px;
font-style: italic;
}
.summary-wrap {
margin-top: 16px;
display: flex;
justify-content: flex-end;
}
.summary { width: 280px; }
.summary-row {
display: flex;
justify-content: space-between;
gap: 24px;
padding: 3px 0;
color: #444444;
font-size: 11px;
}
.summary-row .amount { color: #111111; font-weight: 600; }
.grand-row {
display: flex;
justify-content: space-between;
gap: 24px;
margin-top: 6px;
padding: 8px 0;
border-top: 1.5px solid #111111;
border-bottom: 1.5px solid #111111;
font-size: 14px;
font-weight: 800;
}
.amount-words {
margin-top: 10px;
border: 1px solid #cfcfcf;
background: #f7f7f7;
padding: 8px 10px;
}
.amount-words .words {
margin-top: 2px;
font-weight: 700;
font-size: 11px;
line-height: 1.4;
}
.notes { margin-top: 18px; }
.notes-block { margin-bottom: 12px; }
.notes-block .body {
margin-top: 4px;
color: #444444;
font-size: 10.5px;
line-height: 1.5;
}
.sign-row {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 20px;
margin-top: 28px;
}
.sign-title { font-size: 11px; font-weight: 700; }
.sign-sub {
margin-top: 2px;
color: #666666;
font-size: 10px;
}
.footer {
margin-top: 24px;
padding-top: 10px;
border-top: 1px solid #d8d8d8;
text-align: center;
color: #9a9a9a;
font-size: 9.5px;
}
</style>
</head>
<body>
<div class="document">
${renderCompanyHeader({ company: data.company, title: 'GOODS RECEIPT NOTE', docMetaHtml })}
<div class="party-grid">
${renderPartyCard(
'Received At',
`
<strong>${escapeHtml(data.warehouse.name)}</strong> (${escapeHtml(data.warehouse.code)})<br />
${escapeHtml(data.warehouse.address)}<br />
<strong>${escapeHtml(data.company.name)}</strong>
`
)}
${renderPartyCard(
'Supplier',
`
<strong>${escapeHtml(data.vendor.vendor_name)}</strong><br />
${escapeHtml(data.vendor.address)}, ${escapeHtml(data.vendor.city)}<br />
${escapeHtml(data.vendor.state)} - ${escapeHtml(data.vendor.pincode)}<br />
GSTIN: ${escapeHtml(data.vendor.gstin)}<br />
Contact: ${escapeHtml(data.vendor.contact_name)} (${escapeHtml(data.vendor.phone)})
`
)}
</div>
<div class="info-grid">
<div class="card">
<div class="card-title">Invoice & Transport</div>
<div class="card-body">
<div class="info-row"><span class="info-label">Vendor Invoice No</span><span class="info-value">${escapeHtml(data.grn.vendor_invoice_no)}</span></div>
<div class="info-row"><span class="info-label">Invoice Date</span><span class="info-value">${formatDate(data.grn.vendor_invoice_date)}</span></div>
<div class="info-row"><span class="info-label">Invoice Amount</span><span class="info-value">${formatCurrency(data.grn.vendor_invoice_amount)}</span></div>
<div class="info-row"><span class="info-label">Vehicle No</span><span class="info-value">${escapeHtml(data.grn.vehicle_no)}</span></div>
<div class="info-row"><span class="info-label">LR No / Date</span><span class="info-value">${escapeHtml(data.grn.lr_no)} / ${formatDate(data.grn.lr_date)}</span></div>
<div class="header">
<div>
<h1 class="company-name">${escapeHtml(company.name || 'Company')}</h1>
<div class="company-meta">
${escapeHtml(companyAddressLine(company))}<br />
${escapeHtml(companyContactLine(company))}
</div>
</div>
<div class="card">
<div class="card-title">Receipt Details</div>
<div class="card-body">
<div class="info-row"><span class="info-label">Received By</span><span class="info-value">${escapeHtml(data.grn.received_by)}</span></div>
<div class="info-row"><span class="info-label">Quality Checked By</span><span class="info-value">${escapeHtml(data.grn.quality_checked_by)}</span></div>
<div class="info-row"><span class="info-label">Warehouse</span><span class="info-value">${escapeHtml(data.warehouse.code)}</span></div>
<div class="info-row"><span class="info-label">PO Reference</span><span class="info-value">${escapeHtml(data.grn.po_number)}</span></div>
</div>
<div class="title-block">
<h2 class="doc-title">Goods receipt note</h2>
<div class="doc-number">${escapeHtml(grn.grn_number || '-')}</div>
</div>
</div>
<hr class="divider" />
<div class="meta-row">
<div>
<div class="label">Status</div>
<div class="value">${escapeHtml(grn.status || '-')}</div>
</div>
<div>
<div class="label">Date received</div>
<div class="value">${formatDate(grn.grn_date, { style: 'numeric' })}</div>
</div>
<div>
<div class="label">PO reference</div>
<div class="value">${escapeHtml(grn.po_number || '-')}</div>
</div>
</div>
<div class="party-row" style="margin-top: 18px;">
<div>
<div class="label">Vendor</div>
<div class="party-name">${escapeHtml(vendor.vendor_name || '-')}</div>
<div class="party-body">
${escapeHtml(vendorAddress || '-')}<br />
${vendor.gstin ? `GSTIN: ${escapeHtml(vendor.gstin)}` : ''}
</div>
</div>
<div>
<div class="label">Received at</div>
<div class="party-name">${escapeHtml(warehouse.name || '-')}</div>
<div class="party-body">${escapeHtml(warehouseAddress || '-')}</div>
</div>
</div>
<hr class="divider-light" />
<div class="class-row">
<div>
<div class="label">Vendor invoice</div>
<div class="value">${escapeHtml(grn.vendor_invoice_no || '-')}</div>
</div>
<div>
<div class="label">Invoice date</div>
<div class="value">${formatDate(grn.vendor_invoice_date, { style: 'numeric' })}</div>
</div>
<div>
<div class="label">Vehicle no</div>
<div class="value">${escapeHtml(grn.vehicle_no || '-')}</div>
</div>
<div>
<div class="label">LR no / date</div>
<div class="value">${escapeHtml(lrLine || '-')}</div>
</div>
</div>
<hr class="divider-light" />
<table class="items">
<thead>
<tr>
<th style="width:5%;">S.No</th>
<th>Item</th>
<th style="width:8%;">UOM</th>
<th style="width:10%;" class="text-right">Ordered</th>
<th style="width:10%;" class="text-right">Accepted</th>
<th style="width:9%;" class="text-right">Rejected</th>
<th style="width:12%;" class="text-right">Rate</th>
<th style="width:14%;" class="text-right">Amount</th>
<th class="col-no">#</th>
<th class="col-item">Item description</th>
<th class="col-qty text-right">Ordered</th>
<th class="col-qty text-right">Received</th>
<th class="col-qty text-right">Accepted</th>
<th class="col-qty text-right">Rejected</th>
<th class="col-uom">UOM</th>
<th class="col-rate text-right">Rate</th>
<th class="col-amt text-right">Amount</th>
</tr>
</thead>
<tbody>${itemsRows}</tbody>
<tbody>
${itemsRows}
</tbody>
</table>
<div class="summary-wrap">
<div class="summary-box">
<table>
<tr>
<td>Accepted Value</td>
<td class="text-right">${formatCurrency(data.totals.sub_total)}</td>
</tr>
<tr>
<td class="grand-total">Total Received Value</td>
<td class="text-right grand-total">${formatCurrency(data.totals.grand_total)}</td>
</tr>
</table>
<div class="summary">
<div class="summary-row">
<span>Accepted value</span>
<span class="amount">${formatCurrency(totals.accepted_value)}</span>
</div>
<div class="summary-row">
<span>Rejected value</span>
<span class="amount">${formatCurrency(totals.rejected_value)}</span>
</div>
${
grn.vendor_invoice_amount != null && grn.vendor_invoice_amount !== ''
? `<div class="summary-row">
<span>Vendor invoice amount</span>
<span class="amount">${formatCurrency(grn.vendor_invoice_amount)}</span>
</div>`
: ''
}
<div class="grand-row">
<span>Grand total</span>
<span>${formatCurrency(totals.grand_total)}</span>
</div>
<div class="amount-words">
<div class="label">Amount in words</div>
<div class="words">${escapeHtml(numberToWords(totals.grand_total))}</div>
</div>
</div>
</div>
<div class="amount-words">Amount in Words: ${escapeHtml(numberToWords(data.totals.grand_total))}</div>
<div class="terms">
<h3>Remarks</h3>
<div>${escapeHtml(data.grn.remarks)}</div>
<div class="notes">
${
grn.remarks
? `<div class="notes-block">
<div class="label">Remarks</div>
<div class="body">${escapeHtml(grn.remarks)}</div>
</div>`
: ''
}
</div>
${renderFootnote()}
<div class="sign-row">
<div>
<div class="sign-title">Received by</div>
<div class="sign-sub">${escapeHtml(grn.received_by || '-')}</div>
</div>
<div>
<div class="sign-title">Quality checked by</div>
<div class="sign-sub">${escapeHtml(grn.quality_checked_by || '-')}</div>
</div>
<div>
<div class="sign-title">Authorised signatory</div>
<div class="sign-sub">${escapeHtml(company.name || '')}</div>
</div>
</div>
<div class="footer">
Generated ${escapeHtml(generatedAt)} · This is a system-generated goods receipt note and does not require a physical stamp.
</div>
</div>
</body>
</html>

View File

@ -4,365 +4,471 @@ const { numberToWords } = require('../helpers/numberToWords');
const getDummyPoData = () => ({
company: {
name: 'Acme Infra & Engineering Pvt. Ltd.',
address: '3rd Floor, Business Tower, Sector 62',
city: 'Noida',
state: 'Uttar Pradesh',
pincode: '201301',
gstin: '09AABCA1234K1Z7',
phone: '+91 120 4400 221',
email: 'procurement@acmeinfra.in',
name: 'Bharat Industries Pvt. Ltd.',
address: 'Plot 14, Guindy Industrial Estate',
city: 'Chennai',
state: 'Tamil Nadu',
pincode: '600032',
gstin: '33AABCB1234D1Z5',
phone: '+91 44 4000 1200',
email: 'procurement@bharatindustries.in',
},
po: {
po_number: 'PO-2026-00047',
po_date: '2026-06-20',
expected_delivery_date: '2026-06-30',
po_number: 'PO/2026-27/00007',
po_date: '2026-07-07',
expected_delivery_date: '2026-07-21',
status: 'Approved',
payment_term: '30 Days from Invoice Date',
delivery_term: 'Door Delivery - Main Plant',
po_type: 'Raw material',
revision_no: 1,
payment_term: '30 days credit',
delivery_term: 'FOB (freight on board)',
terms_and_conditions:
'Material must conform to approved specifications. Any variation should be approved in writing before dispatch.',
remarks: 'Please mention PO number in invoice and delivery challan.',
'Payment as per agreed terms. Goods must match PO specification. Shortages or damages must be reported within 48 hours of delivery.',
remarks: 'Priority delivery required for production line restart on 22 Jul 2026.',
},
vendor: {
vendor_name: 'Shree Industrial Supplies',
gstin: '27AAECS7788B1Z2',
address: 'Plot 17, MIDC Industrial Area',
city: 'Pune',
state: 'Maharashtra',
pincode: '411019',
contact_name: 'Rajesh Patil',
phone: '+91 98220 12345',
vendor_name: 'Tata Steel Limited',
gstin: '20AABCT3456A1Z9',
address: 'Bistupur Main Road',
city: 'Jamshedpur',
state: 'Jharkhand',
pincode: '831001',
},
ship_to: {
name: 'Chennai manufacturing plant',
address: 'Guindy Industrial Estate',
city: 'Chennai',
state: 'Tamil Nadu',
pincode: '600032',
},
brand: { name: 'Tata Tiscon' },
warehouse: { name: 'Main warehouse' },
items: [
{
line_no: 1,
item_name: 'MS Channel 100x50 mm',
uom: 'KG',
ordered_qty: 1250,
rate: 72.5,
item_name: 'TMT steel bars 12mm',
uom: 'MT',
ordered_qty: 25,
rate: 4250,
discount_pct: 2,
gst_rate_pct: 18,
taxable_amount: 90625,
tax_amount: 16312.5,
line_total: 106937.5,
line_total: 124215,
},
{
line_no: 2,
item_name: 'Industrial Fasteners Set M12',
uom: 'BOX',
ordered_qty: 40,
rate: 1425,
gst_rate_pct: 18,
taxable_amount: 57000,
tax_amount: 10260,
line_total: 67260,
item_name: 'Cement OPC 53 grade',
uom: 'Bag',
ordered_qty: 500,
rate: 380,
discount_pct: 0,
gst_rate_pct: 28,
line_total: 243200,
},
{
line_no: 3,
item_name: 'Welding Rod E6013',
uom: 'BOX',
ordered_qty: 30,
rate: 985,
gst_rate_pct: 12,
taxable_amount: 29550,
tax_amount: 3546,
line_total: 33096,
},
{
line_no: 4,
item_name: 'Safety Gloves (Heat Resistant)',
uom: 'PAIR',
ordered_qty: 200,
rate: 210,
item_name: 'River sand',
remarks: 'Confirm bag count on delivery',
uom: 'CFT',
ordered_qty: 1200,
rate: 45,
discount_pct: 5,
gst_rate_pct: 5,
taxable_amount: 42000,
tax_amount: 2100,
line_total: 44100,
line_total: 53865,
},
],
totals: {
sub_total: 219175,
tax_total: 32218.5,
freight_charges: 2500,
other_charges: 850,
grand_total: 254743.5,
sub_total: 119775,
tax_total: 19768.75,
freight_charges: 750,
other_charges: 150,
discount_amount: 500,
grand_total: 139943.75,
},
generated_at: '2026-07-07 14:32 IST',
});
const escapeHtml = (value = '') =>
String(value)
String(value ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
const calculateTaxSplit = (taxTotal) => {
const halfTax = Number((Number(taxTotal || 0) / 2).toFixed(2));
return { cgst: halfTax, sgst: halfTax };
const joinParts = (...parts) => parts.map((p) => String(p || '').trim()).filter(Boolean).join(', ');
const companyAddressLine = (company = {}) =>
joinParts(company.address, company.city, company.state, company.pincode);
const companyContactLine = (company = {}) => {
const parts = [];
if (company.gstin) parts.push(`GSTIN: ${company.gstin}`);
if (company.email) parts.push(company.email);
if (company.phone) parts.push(company.phone);
return parts.join(' · ');
};
const qtyDisplay = (value) => {
const num = Number(value || 0);
return Number.isInteger(num) ? String(num) : num.toFixed(2);
};
const generatePoHtml = (inputData = getDummyPoData()) => {
const data = inputData || getDummyPoData();
const taxSplit = calculateTaxSplit(data.totals.tax_total);
const company = data.company || {};
const po = data.po || {};
const vendor = data.vendor || {};
const shipTo = data.ship_to || {};
const brand = data.brand || {};
const warehouse = data.warehouse || {};
const totals = data.totals || {};
const itemsRows = (data.items || [])
.map(
(item, index) => `
.map((item, index) => {
const remarks = item.remarks
? `<div class="item-note">*${escapeHtml(item.remarks)}</div>`
: '';
return `
<tr>
<td>${item.line_no || index + 1}</td>
<td class="item-name">${escapeHtml(item.item_name)}</td>
<td>${escapeHtml(item.uom)}</td>
<td class="text-right">${Number(item.ordered_qty || 0).toFixed(2)}</td>
<td class="text-right">${formatCurrency(item.rate)}</td>
<td class="text-right">${Number(item.gst_rate_pct || 0).toFixed(2)}%</td>
<td class="text-right">${formatCurrency(item.line_total)}</td>
<td class="col-no">${item.line_no || index + 1}</td>
<td class="col-item">
<div class="item-name">${escapeHtml(item.item_name || '-')}</div>
${remarks}
</td>
<td class="col-qty text-right">${qtyDisplay(item.ordered_qty)}</td>
<td class="col-uom">${escapeHtml(item.uom || '-')}</td>
<td class="col-rate text-right">${formatCurrency(item.rate)}</td>
<td class="col-disc text-right">${Number(item.discount_pct || 0).toFixed(0)}%</td>
<td class="col-gst text-right">${Number(item.gst_rate_pct || 0).toFixed(0)}%</td>
<td class="col-amt text-right">${formatCurrency(item.line_total)}</td>
</tr>
`
)
`;
})
.join('');
const discountValue = Number(totals.discount_amount || 0);
const discountFormatted = discountValue > 0 ? `-${formatCurrency(discountValue)}` : formatCurrency(0);
const vendorAddress = joinParts(vendor.address, vendor.city, vendor.state, vendor.pincode);
const shipAddress = joinParts(shipTo.address, shipTo.city, shipTo.state, shipTo.pincode);
const generatedAt = data.generated_at || formatDate(new Date(), { style: 'datetime' });
return `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Purchase Order</title>
<title>Purchase Order ${escapeHtml(po.po_number || '')}</title>
<style>
* { box-sizing: border-box; }
body {
margin: 0;
font-family: Arial, Helvetica, sans-serif;
color: #111827;
font-family: Helvetica, Arial, sans-serif;
color: #111111;
background: #ffffff;
font-size: 12px;
line-height: 1.5;
font-size: 11px;
line-height: 1.45;
}
.document {
width: 100%;
padding: 0;
.document { width: 100%; padding: 0; }
.muted { color: #8a8a8a; }
.label {
color: #9a9a9a;
font-size: 9px;
letter-spacing: 0.08em;
text-transform: uppercase;
font-weight: 600;
}
.value { color: #111111; font-weight: 700; }
.text-right { text-align: right; }
.divider {
border: 0;
border-top: 1.5px solid #111111;
margin: 14px 0;
}
.divider-light {
border: 0;
border-top: 1px solid #d8d8d8;
margin: 14px 0;
}
.header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
gap: 24px;
}
.brand {
display: flex;
gap: 12px;
align-items: flex-start;
flex: 1;
}
.logo {
width: 56px;
height: 56px;
border-radius: 8px;
background: #1d4ed8;
color: #ffffff;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
letter-spacing: 1px;
}
.company h1 {
.company-name {
margin: 0;
font-size: 16px;
color: #1d4ed8;
}
.company .meta {
margin-top: 4px;
color: #374151;
}
.title-block {
text-align: right;
min-width: 280px;
}
.title {
margin: 0;
color: #1d4ed8;
font-size: 28px;
font-size: 20px;
font-weight: 800;
letter-spacing: 0.7px;
letter-spacing: -0.02em;
}
.doc-meta {
margin-top: 6px;
border: 1px solid #bfdbfe;
border-radius: 8px;
padding: 8px 10px;
background: #eff6ff;
.company-meta {
margin-top: 4px;
color: #555555;
font-size: 10.5px;
line-height: 1.5;
}
.status {
display: inline-block;
background: #1d4ed8;
color: #ffffff;
font-size: 10px;
font-weight: 700;
padding: 2px 8px;
border-radius: 10px;
.title-block { text-align: right; min-width: 210px; }
.doc-title {
margin: 0;
font-size: 26px;
font-weight: 800;
letter-spacing: -0.02em;
}
.party-grid {
margin-top: 14px;
.doc-number {
margin-top: 2px;
color: #555555;
font-size: 12px;
}
.meta-row {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 16px;
margin-top: 4px;
}
.meta-row .value { margin-top: 2px; font-size: 12px; }
.party-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
gap: 28px;
margin-top: 4px;
}
.card {
border: 1px solid #dbeafe;
border-radius: 8px;
overflow: hidden;
.party-name {
margin-top: 4px;
font-size: 13px;
font-weight: 800;
}
.card-title {
background: #1d4ed8;
color: #ffffff;
padding: 8px 10px;
font-weight: 700;
.party-body {
margin-top: 2px;
color: #444444;
font-size: 10.5px;
line-height: 1.5;
}
.card-body {
padding: 10px;
.party-term {
margin-top: 6px;
color: #444444;
font-size: 10.5px;
}
table {
.class-row {
display: grid;
grid-template-columns: 1fr 1fr 1fr 1fr;
gap: 16px;
margin-top: 4px;
}
.class-row .value { margin-top: 2px; font-size: 12px; }
table.items {
width: 100%;
border-collapse: collapse;
margin-top: 2px;
}
.items {
margin-top: 12px;
border: 1px solid #d1d5db;
border-radius: 8px;
overflow: hidden;
}
.items thead th {
background: #1d4ed8;
color: #ffffff;
padding: 8px 6px;
font-size: 11px;
table.items thead th {
padding: 8px 4px;
border-bottom: 1.5px solid #111111;
color: #9a9a9a;
font-size: 9px;
letter-spacing: 0.08em;
text-transform: uppercase;
font-weight: 600;
text-align: left;
}
.items tbody td {
padding: 8px 6px;
border-bottom: 1px solid #e5e7eb;
table.items tbody td {
padding: 10px 4px;
border-bottom: 1px solid #e5e5e5;
vertical-align: top;
font-size: 11px;
}
.items tbody tr:nth-child(even) {
background: #f9fafb;
}
.text-right {
text-align: right;
}
.item-name {
width: 34%;
.col-no { width: 4%; color: #666666; }
.col-item { width: 36%; }
.col-qty { width: 8%; }
.col-uom { width: 8%; }
.col-rate { width: 12%; }
.col-disc { width: 8%; }
.col-gst { width: 8%; }
.col-amt { width: 16%; }
.item-name { font-weight: 600; }
.item-note {
margin-top: 2px;
color: #888888;
font-size: 9.5px;
font-style: italic;
}
.summary-wrap {
margin-top: 12px;
margin-top: 16px;
display: flex;
justify-content: flex-end;
}
.summary-box {
width: 340px;
border: 1px solid #dbeafe;
border-radius: 8px;
overflow: hidden;
.summary {
width: 280px;
}
.summary-box table td {
padding: 8px 10px;
}
.summary-box tr:nth-child(even) {
background: #f9fafb;
}
.grand-total {
font-size: 16px;
font-weight: 800;
color: #1d4ed8;
}
.amount-words {
margin-top: 8px;
border: 1px dashed #93c5fd;
border-radius: 8px;
padding: 8px 10px;
font-weight: 600;
color: #1e40af;
}
.terms {
margin-top: 14px;
border: 1px solid #dbeafe;
border-radius: 8px;
padding: 10px 12px;
}
.terms h3 {
margin: 0 0 8px;
color: #1d4ed8;
font-size: 14px;
}
.footnote {
margin-top: 16px;
.summary-row {
display: flex;
justify-content: space-between;
color: #6b7280;
gap: 24px;
padding: 3px 0;
color: #444444;
font-size: 11px;
}
.summary-row .amount { color: #111111; font-weight: 600; }
.grand-row {
display: flex;
justify-content: space-between;
gap: 24px;
margin-top: 6px;
padding: 8px 0;
border-top: 1.5px solid #111111;
border-bottom: 1.5px solid #111111;
font-size: 14px;
font-weight: 800;
}
.amount-words {
margin-top: 10px;
border: 1px solid #cfcfcf;
background: #f7f7f7;
padding: 8px 10px;
}
.amount-words .words {
margin-top: 2px;
font-weight: 700;
font-size: 11px;
line-height: 1.4;
}
.notes {
margin-top: 18px;
}
.notes-block { margin-bottom: 12px; }
.notes-block .body {
margin-top: 4px;
color: #444444;
font-size: 10.5px;
line-height: 1.5;
}
.sign-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 28px;
margin-top: 28px;
}
.sign-title {
font-size: 11px;
font-weight: 700;
}
.sign-sub {
margin-top: 2px;
color: #666666;
font-size: 10px;
}
.page-number:before {
content: "Page " counter(page);
.footer {
margin-top: 24px;
padding-top: 10px;
border-top: 1px solid #d8d8d8;
text-align: center;
color: #9a9a9a;
font-size: 9.5px;
}
</style>
</head>
<body>
<div class="document">
<div class="header">
<div class="brand">
<div class="logo">AI</div>
<div class="company">
<h1>${escapeHtml(data.company.name)}</h1>
<div class="meta">
${escapeHtml(data.company.address)}, ${escapeHtml(data.company.city)}, ${escapeHtml(data.company.state)} - ${escapeHtml(data.company.pincode)}<br />
GSTIN: ${escapeHtml(data.company.gstin)} | Phone: ${escapeHtml(data.company.phone)}<br />
Email: ${escapeHtml(data.company.email)}
</div>
<div>
<h1 class="company-name">${escapeHtml(company.name || 'Company')}</h1>
<div class="company-meta">
${escapeHtml(companyAddressLine(company))}<br />
${escapeHtml(companyContactLine(company))}
</div>
</div>
<div class="title-block">
<h2 class="title">PURCHASE ORDER</h2>
<div class="doc-meta">
<div><strong>PO No:</strong> ${escapeHtml(data.po.po_number)}</div>
<div><strong>Date:</strong> ${formatDate(data.po.po_date)}</div>
<div><strong>Delivery:</strong> ${formatDate(data.po.expected_delivery_date)}</div>
<div style="margin-top: 4px;"><span class="status">${escapeHtml(data.po.status)}</span></div>
</div>
<h2 class="doc-title">Purchase order</h2>
<div class="doc-number">${escapeHtml(po.po_number || '-')}</div>
</div>
</div>
<div class="party-grid">
<div class="card">
<div class="card-title">From</div>
<div class="card-body">
<strong>${escapeHtml(data.company.name)}</strong><br />
${escapeHtml(data.company.address)}, ${escapeHtml(data.company.city)}<br />
${escapeHtml(data.company.state)} - ${escapeHtml(data.company.pincode)}<br />
GSTIN: ${escapeHtml(data.company.gstin)}<br />
Phone: ${escapeHtml(data.company.phone)}
</div>
<hr class="divider" />
<div class="meta-row">
<div>
<div class="label">Status</div>
<div class="value">${escapeHtml(po.status || '-')}</div>
</div>
<div class="card">
<div class="card-title">To</div>
<div class="card-body">
<strong>${escapeHtml(data.vendor.vendor_name)}</strong><br />
${escapeHtml(data.vendor.address)}, ${escapeHtml(data.vendor.city)}<br />
${escapeHtml(data.vendor.state)} - ${escapeHtml(data.vendor.pincode)}<br />
GSTIN: ${escapeHtml(data.vendor.gstin)}<br />
Contact: ${escapeHtml(data.vendor.contact_name)} (${escapeHtml(data.vendor.phone)})
</div>
<div>
<div class="label">Date issued</div>
<div class="value">${formatDate(po.po_date, { style: 'numeric' })}</div>
</div>
<div>
<div class="label">Expected delivery</div>
<div class="value">${formatDate(po.expected_delivery_date, { style: 'numeric' })}</div>
</div>
</div>
<div class="party-row" style="margin-top: 18px;">
<div>
<div class="label">Vendor</div>
<div class="party-name">${escapeHtml(vendor.vendor_name || '-')}</div>
<div class="party-body">
${escapeHtml(vendorAddress || '-')}<br />
${vendor.gstin ? `GSTIN: ${escapeHtml(vendor.gstin)}` : ''}
</div>
${
po.payment_term
? `<div class="party-term">Payment term: ${escapeHtml(po.payment_term)}</div>`
: ''
}
</div>
<div>
<div class="label">Ship to</div>
<div class="party-name">${escapeHtml(shipTo.name || '-')}</div>
<div class="party-body">${escapeHtml(shipAddress || '-')}</div>
${
po.delivery_term
? `<div class="party-term">Delivery term: ${escapeHtml(po.delivery_term)}</div>`
: ''
}
</div>
</div>
<hr class="divider-light" />
<div class="class-row">
<div>
<div class="label">PO type</div>
<div class="value">${escapeHtml(po.po_type || '-')}</div>
</div>
<div>
<div class="label">Brand</div>
<div class="value">${escapeHtml(brand.name || '-')}</div>
</div>
<div>
<div class="label">Warehouse</div>
<div class="value">${escapeHtml(warehouse.name || '-')}</div>
</div>
<div>
<div class="label">Revision</div>
<div class="value">${escapeHtml(String(po.revision_no ?? 0))}</div>
</div>
</div>
<hr class="divider-light" />
<table class="items">
<thead>
<tr>
<th style="width: 6%;">S.No</th>
<th>Item</th>
<th style="width: 9%;">UOM</th>
<th style="width: 11%;" class="text-right">Qty</th>
<th style="width: 14%;" class="text-right">Rate</th>
<th style="width: 10%;" class="text-right">GST%</th>
<th style="width: 16%;" class="text-right">Amount</th>
<th class="col-no">#</th>
<th class="col-item">Item description</th>
<th class="col-qty text-right">Qty</th>
<th class="col-uom">UOM</th>
<th class="col-rate text-right">Rate</th>
<th class="col-disc text-right">Disc %</th>
<th class="col-gst text-right">GST</th>
<th class="col-amt text-right">Amount</th>
</tr>
</thead>
<tbody>
@ -371,49 +477,70 @@ const generatePoHtml = (inputData = getDummyPoData()) => {
</table>
<div class="summary-wrap">
<div class="summary-box">
<table>
<tr>
<td>Sub Total</td>
<td class="text-right">${formatCurrency(data.totals.sub_total)}</td>
</tr>
<tr>
<td>CGST</td>
<td class="text-right">${formatCurrency(taxSplit.cgst)}</td>
</tr>
<tr>
<td>SGST</td>
<td class="text-right">${formatCurrency(taxSplit.sgst)}</td>
</tr>
<tr>
<td>Freight</td>
<td class="text-right">${formatCurrency(data.totals.freight_charges)}</td>
</tr>
<tr>
<td>Other Charges</td>
<td class="text-right">${formatCurrency(data.totals.other_charges)}</td>
</tr>
<tr>
<td class="grand-total">Grand Total</td>
<td class="text-right grand-total">${formatCurrency(data.totals.grand_total)}</td>
</tr>
</table>
<div class="summary">
<div class="summary-row">
<span>Taxable amount</span>
<span class="amount">${formatCurrency(totals.sub_total)}</span>
</div>
<div class="summary-row">
<span>Tax (GST)</span>
<span class="amount">${formatCurrency(totals.tax_total)}</span>
</div>
<div class="summary-row">
<span>Freight charges</span>
<span class="amount">${formatCurrency(totals.freight_charges)}</span>
</div>
<div class="summary-row">
<span>Other charges</span>
<span class="amount">${formatCurrency(totals.other_charges)}</span>
</div>
<div class="summary-row">
<span>Discount</span>
<span class="amount">${discountFormatted}</span>
</div>
<div class="grand-row">
<span>Grand total</span>
<span>${formatCurrency(totals.grand_total)}</span>
</div>
<div class="amount-words">
<div class="label">Amount in words</div>
<div class="words">${escapeHtml(numberToWords(totals.grand_total))}</div>
</div>
</div>
</div>
<div class="amount-words">Amount in Words: ${escapeHtml(numberToWords(data.totals.grand_total))}</div>
<div class="terms">
<h3>Terms & Conditions</h3>
<div><strong>Payment Term:</strong> ${escapeHtml(data.po.payment_term)}</div>
<div><strong>Delivery Term:</strong> ${escapeHtml(data.po.delivery_term)}</div>
<div style="margin-top: 6px;">${escapeHtml(data.po.terms_and_conditions)}</div>
<div style="margin-top: 6px;"><strong>Remarks:</strong> ${escapeHtml(data.po.remarks)}</div>
<div class="notes">
${
po.terms_and_conditions
? `<div class="notes-block">
<div class="label">Terms and conditions</div>
<div class="body">${escapeHtml(po.terms_and_conditions)}</div>
</div>`
: ''
}
${
po.remarks
? `<div class="notes-block">
<div class="label">Remarks</div>
<div class="body">${escapeHtml(po.remarks)}</div>
</div>`
: ''
}
</div>
<div class="footnote">
<span>This is a computer generated document.</span>
<span class="page-number"></span>
<div class="sign-row">
<div>
<div class="sign-title">Authorised signatory</div>
<div class="sign-sub">${escapeHtml(company.name || '')}</div>
</div>
<div>
<div class="sign-title">Accepted by vendor</div>
<div class="sign-sub">${escapeHtml(vendor.vendor_name || '')}</div>
</div>
</div>
<div class="footer">
Generated ${escapeHtml(generatedAt)} · This is a system-generated purchase order and does not require a physical stamp.
</div>
</div>
</body>