GWM : pdf desigh for pd and grn
This commit is contained in:
parent
82676316c6
commit
969647333f
@ -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`
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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),
|
||||
|
||||
4
scripts/patch-company-gstin.sql
Normal file
4
scripts/patch-company-gstin.sql
Normal 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);
|
||||
@ -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' }
|
||||
|
||||
@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@ -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 || '',
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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 };
|
||||
|
||||
@ -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 };
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
@ -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, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
|
||||
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>
|
||||
|
||||
@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
|
||||
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>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user