PO Re-Design

This commit is contained in:
Gowtham M 2026-07-17 11:12:51 +05:30
parent c7543d2e3b
commit 3590fbb39f
5 changed files with 788 additions and 672 deletions

View File

@ -73,6 +73,7 @@ const grnPdfInclude = {
code: true,
name: true,
type: true,
gstin: true,
address: true,
city: true,
state: true,
@ -220,10 +221,21 @@ const buildGrnPdfPayload = (grn, company) => {
},
warehouse: {
name: grn.location?.name || '-',
gstin: grn.location?.gstin || '',
address: grn.location?.address || '',
city: grn.location?.city || '',
state: grn.location?.state || '',
pincode: grn.location?.pincode || '',
type: grn.location?.type || null,
},
location: {
name: grn.location?.name || '-',
gstin: grn.location?.gstin || '',
address: grn.location?.address || '',
city: grn.location?.city || '',
state: grn.location?.state || '',
pincode: grn.location?.pincode || '',
type: grn.location?.type || null,
},
items,
generated_at: `${formatDate(new Date(), { style: 'datetime' })} IST`,

View File

@ -31,25 +31,26 @@ const computeHeaderTotals = (
other = 0,
{ tdsApplicable = false, tdsSectionPct = 0, adjustment = 0 } = {}
) => {
// Sub total = sum of line taxable amounts (after line-level discounts).
const subTotal = round4(lines.reduce((sum, line) => sum + toNum(line.taxable_amount), 0));
const taxTotal = round4(lines.reduce((sum, line) => sum + toNum(line.tax_amount), 0));
const headerDiscountAmount = round4(headerDiscount);
const freightCharges = round4(freight);
const otherCharges = round4(other);
// Taxable amount = Sub Total + Freight + Other Discount (header-level).
const taxableAmount = round4(subTotal + freightCharges + otherCharges - headerDiscountAmount);
// TDS is calculated on sub total (line taxable), not on header taxable.
const tdsAmount = tdsApplicable ? round4((subTotal * toNum(tdsSectionPct)) / 100) : 0;
const adjustmentAmount = round4(adjustment);
const grandTotal = round4(
subTotal +
taxTotal +
freightCharges +
otherCharges -
headerDiscountAmount -
tdsAmount +
adjustmentAmount
);
// Grand total = Taxable Amount + Tax Total TDS + Adjustment.
const grandTotal = round4(taxableAmount + taxTotal - tdsAmount + adjustmentAmount);
return {
sub_total: subTotal,
taxable_amount: taxableAmount,
tax_total: taxTotal,
discount_amount: headerDiscountAmount,
freight_charges: freightCharges,

View File

@ -21,6 +21,7 @@ const {
computeHeaderTotals,
splitGst,
toNum,
round4,
} = require('./purchase-orders.calculations');
const repository = require('./purchase-orders.repository');
const { assertAnyLocation } = require('../../utils/locations');
@ -143,6 +144,13 @@ const sanitizePo = (po) => {
return {
...rest,
// Header taxable = sub_total + freight + other discount (not a DB column).
taxable_amount: round4(
toNum(rest.sub_total) +
toNum(rest.freight_charges) +
toNum(rest.other_charges) -
toNum(rest.discount_amount)
),
vendor: vendors || null,
billing: billing_location || null,
shipping: shipping_location || null,
@ -312,8 +320,10 @@ const buildHeaderData = async (payload, builtItems, userId, { applyDefaultTerms
adjustment: payload.adjustment,
}
);
// taxable_amount is derived for API/PDF; not a persisted PO column.
const { taxable_amount: _taxableAmount, ...persistedTotals } = totals;
const gstSplit = splitGst(totals.tax_total, isInterStateSupply(billing, vendor));
const gstSplit = splitGst(persistedTotals.tax_total, isInterStateSupply(billing, vendor));
let termsAndConditions =
payload.terms_and_conditions !== undefined && payload.terms_and_conditions !== null
@ -339,7 +349,7 @@ const buildHeaderData = async (payload, builtItems, userId, { applyDefaultTerms
remarks: payload.remarks || null,
tds_applicable: tdsApplicable,
tds_section_pct: tdsApplicable ? tdsSectionPct : null,
...totals,
...persistedTotals,
...gstSplit,
updated_by: userId ? BigInt(userId) : null,
};
@ -436,18 +446,21 @@ const buildPoPdfPayload = (po, company) => {
rate,
discount_pct: toNum(line.discount_pct),
gst_rate_pct: toNum(line.gst_rate?.rate_pct),
amount: orderedQty * rate,
amount: toNum(line.taxable_amount != null ? line.taxable_amount : orderedQty * rate),
};
}),
totals: {
sub_total: toNum(po.sub_total),
tax_total: toNum(po.tax_total),
cgst: toNum(po.cgst),
sgst: toNum(po.sgst),
igst: toNum(po.igst),
freight_charges: toNum(po.freight_charges),
other_charges: toNum(po.other_charges),
discount_amount: toNum(po.discount_amount),
taxable_amount: toNum(po.taxable_amount),
cgst: toNum(po.cgst),
sgst: toNum(po.sgst),
igst: toNum(po.igst),
tax_total: toNum(po.tax_total),
tds_amount: toNum(po.tds_amount),
adjustment: toNum(po.adjustment),
grand_total: toNum(po.grand_total),
},
generated_at: `${formatDate(new Date(), { style: 'datetime' })} IST`,

View File

@ -1,21 +1,28 @@
const { formatDate } = require('../helpers/formatDate');
const BRAND = '#1e3a5f';
const BRAND_SOFT = '#eef3f8';
const STATUS_BG = '#fde8d0';
const STATUS_FG = '#9a5b16';
const MUTED = '#6b7280';
const LINE = '#e5e7eb';
const getDummyGrnData = () => ({
company: {
name: 'Bharat Industries Pvt. Ltd.',
address: 'Plot 14, Guindy Industrial Estate',
name: 'BCPL Chennai',
address: 'Chennai',
city: 'Chennai',
state: 'Tamil Nadu',
pincode: '600032',
gstin: '33AABCB1234D1Z5',
phone: '+91 44 4000 1200',
email: 'procurement@bharatindustries.in',
state: 'TN',
pincode: '665544',
gstin: '29ABCDE1234F1Z5',
phone: '6369084112',
email: 'venba2026@gmail.com',
},
grn: {
grn_number: 'GRN/2026-27/00012',
grn_date: '2026-07-14',
status: 'Posted',
po_number: 'PO/2026-27/00007',
po_number: 'PO/2026-27/00038',
vendor_invoice_no: 'TSL/INV/2026/441',
vendor_invoice_date: '2026-07-13',
vehicle_no: 'TN-09-AB-4521',
@ -26,31 +33,33 @@ const getDummyGrnData = () => ({
remarks: 'All items inspected and accepted except partial rejection on line 3.',
},
vendor: {
vendor_name: 'Tata Steel Limited',
gstin: '20AABCT3456A1Z9',
address: 'Bistupur Main Road',
city: 'Jamshedpur',
state: 'Jharkhand',
pincode: '831001',
},
warehouse: {
name: 'Main warehouse',
address: 'Guindy Industrial Estate',
vendor_name: 'G E Capital Business Process Management Service Limited, Chennai Branch',
gstin: '29ABCDE1234F1Z5',
address: 'B6/5 Police Qrts, Alandur, Raja Street',
city: 'Chennai',
state: 'Tamil Nadu',
pincode: '600032',
pincode: '600117',
},
location: {
name: 'Ahmedabad Warehouse',
gstin: '24AAACT1234A1Z8',
address: 'GIDC Vatva, Phase II',
city: 'Ahmedabad',
state: 'Gujarat',
pincode: '382445',
type: 'warehouse',
},
items: [
{
line_no: 1,
item_name: 'TMT steel bars 12mm',
item_code: 'ITM-TMT-12',
uom: 'MT',
ordered_qty: 25,
current_qty: 25,
accepted_qty: 25,
item_name: 'Bootle',
item_code: 'ITM-BTL-01',
uom: 'BTL',
ordered_qty: 500,
current_qty: 500,
accepted_qty: 500,
rejected_qty: 0,
batch_no: 'BAT-TMT-2407',
batch_no: 'BAT-BTL-01',
mfg_date: '2026-06-01',
expiry_date: null,
rejection_reason: null,
@ -59,38 +68,22 @@ const getDummyGrnData = () => ({
},
{
line_no: 2,
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,
batch_no: 'CEM-JUN-26',
item_name: 'Raw Materials - 1',
item_code: 'ITM-RM-01',
uom: 'KG',
ordered_qty: 1000,
current_qty: 980,
accepted_qty: 960,
rejected_qty: 20,
batch_no: 'RM-JUL-26',
mfg_date: '2026-05-15',
expiry_date: '2027-05-15',
rejection_reason: null,
rejection_reason: 'Damaged packaging',
storage_location: 'Shed B',
remarks: null,
},
{
line_no: 3,
item_name: 'River sand',
item_code: 'ITM-SND-01',
uom: 'CFT',
ordered_qty: 1200,
current_qty: 1200,
accepted_qty: 1180,
rejected_qty: 20,
batch_no: null,
mfg_date: null,
expiry_date: null,
rejection_reason: 'Damaged bags in transit',
storage_location: 'Yard 2',
remarks: 'Confirm bag count on delivery',
remarks: 'Confirm count on delivery',
},
],
generated_at: '14/07/2026 09:40',
generated_at: '14/07/2026 09:40 IST',
});
const escapeHtml = (value = '') =>
@ -124,12 +117,247 @@ const displayOrDash = (value) => {
return String(value);
};
const renderAddressBlock = (party = {}) => {
const lines = [];
if (party.name || party.vendor_name) {
lines.push(
`<div class="party-name">${escapeHtml(party.name || party.vendor_name || '-')}</div>`
);
}
if (party.address) {
lines.push(`<div class="party-line">${escapeHtml(party.address)}</div>`);
}
const cityLine = joinParts(party.city, party.state, party.pincode);
if (cityLine) {
lines.push(`<div class="party-line">${escapeHtml(cityLine)}</div>`);
}
if (party.gstin) {
lines.push(`<div class="party-line">GSTIN: ${escapeHtml(party.gstin)}</div>`);
}
if (!lines.length) {
lines.push(`<div class="party-line">-</div>`);
}
return lines.join('');
};
const sharedDocumentStyles = () => `
* { box-sizing: border-box; }
body {
margin: 0;
font-family: Helvetica, Arial, sans-serif;
color: #111827;
background: #ffffff;
font-size: 11px;
line-height: 1.45;
}
.document { width: 100%; padding: 0; }
.text-right { text-align: right; }
.label {
color: ${MUTED};
font-size: 9px;
letter-spacing: 0.08em;
text-transform: uppercase;
font-weight: 700;
}
.header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 24px;
}
.brand {
display: flex;
gap: 12px;
align-items: flex-start;
flex: 1;
min-width: 0;
}
.company-logo {
width: 52px;
height: 52px;
object-fit: contain;
flex-shrink: 0;
}
.company-name {
margin: 0;
font-size: 20px;
font-weight: 800;
color: ${BRAND};
letter-spacing: -0.02em;
}
.company-meta {
margin-top: 4px;
color: #4b5563;
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;
color: ${BRAND};
letter-spacing: -0.02em;
}
.doc-number {
margin-top: 4px;
color: #374151;
font-size: 12px;
font-weight: 600;
}
.meta-bar {
margin-top: 16px;
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 0;
border-top: 1px solid ${LINE};
border-bottom: 1px solid ${LINE};
background: #fafbfc;
}
.meta-cell {
padding: 10px 14px;
border-right: 1px solid ${LINE};
}
.meta-cell:last-child { border-right: 0; }
.meta-cell .value {
margin-top: 4px;
font-size: 12px;
font-weight: 700;
color: #111827;
}
.status-pill {
display: inline-block;
margin-top: 4px;
padding: 3px 10px;
border-radius: 999px;
background: ${STATUS_BG};
color: ${STATUS_FG};
font-size: 11px;
font-weight: 700;
}
.party-grid {
margin-top: 16px;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px 28px;
}
.party-name {
margin-top: 4px;
font-size: 12.5px;
font-weight: 800;
color: #111827;
}
.party-line {
margin-top: 2px;
color: #4b5563;
font-size: 10.5px;
line-height: 1.45;
}
.info-grid {
margin-top: 14px;
display: grid;
grid-template-columns: 1fr 1fr 1fr 1fr;
gap: 12px 16px;
padding: 12px 14px;
background: #fafbfc;
border: 1px solid ${LINE};
}
.info-grid .value {
margin-top: 3px;
font-size: 11.5px;
font-weight: 700;
color: #111827;
}
table.items {
width: 100%;
border-collapse: collapse;
margin-top: 18px;
}
table.items thead th {
background: ${BRAND};
color: #ffffff;
padding: 9px 4px;
font-size: 8px;
letter-spacing: 0.05em;
text-transform: uppercase;
font-weight: 700;
text-align: left;
}
table.items thead th.text-right { text-align: right; }
table.items tbody td {
padding: 9px 4px;
border-bottom: 1px solid ${LINE};
vertical-align: top;
font-size: 10px;
}
table.items tbody tr:nth-child(even) { background: ${BRAND_SOFT}; }
.col-no { width: 3%; color: #6b7280; }
.col-item { width: 20%; }
.col-qty { width: 7%; }
.col-uom { width: 5%; }
.col-batch { width: 10%; }
.col-date { width: 9%; }
.col-reason { width: 14%; }
.item-name { font-weight: 700; }
.item-code {
margin-top: 1px;
color: #888888;
font-size: 9px;
}
.item-note {
margin-top: 2px;
color: #888888;
font-size: 9px;
font-style: italic;
}
.notes { margin-top: 16px; }
.notes-block { margin-bottom: 10px; }
.notes-block .body {
margin-top: 4px;
color: #4b5563;
font-size: 10.5px;
line-height: 1.5;
white-space: pre-wrap;
}
.sign-row {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 24px;
margin-top: 36px;
}
.sign-line {
border-top: 1px solid #9ca3af;
margin-top: 48px;
padding-top: 8px;
}
.sign-title { font-size: 11px; font-weight: 700; color: #111827; }
.sign-sub {
margin-top: 2px;
color: #6b7280;
font-size: 10px;
}
.footer {
margin-top: 28px;
text-align: center;
color: #9ca3af;
font-size: 9.5px;
}
`;
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 location = data.location || data.warehouse || {};
const itemsRows = (data.items || [])
.map((item, index) => {
@ -165,13 +393,6 @@ const generateGrnHtml = (inputData = getDummyGrnData()) => {
})
.join('');
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)
@ -183,190 +404,7 @@ const generateGrnHtml = (inputData = getDummyGrnData()) => {
<head>
<meta charset="UTF-8" />
<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;
}
.brand {
display: flex;
gap: 12px;
align-items: flex-start;
flex: 1;
min-width: 0;
}
.company-logo {
width: 56px;
height: 56px;
object-fit: contain;
flex-shrink: 0;
}
.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: 8px;
letter-spacing: 0.05em;
text-transform: uppercase;
font-weight: 600;
text-align: left;
}
table.items thead th.text-right {
text-align: right;
}
table.items tbody td {
padding: 9px 3px;
border-bottom: 1px solid #e5e5e5;
vertical-align: top;
font-size: 10px;
}
.col-no { width: 3%; color: #666666; }
.col-item { width: 20%; }
.col-qty { width: 7%; }
.col-uom { width: 5%; }
.col-batch { width: 10%; }
.col-date { width: 9%; }
.col-reason { width: 14%; }
.item-name { font-weight: 600; }
.item-code {
margin-top: 1px;
color: #888888;
font-size: 9px;
}
.item-note {
margin-top: 2px;
color: #888888;
font-size: 9px;
font-style: italic;
}
.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>
<style>${sharedDocumentStyles()}</style>
</head>
<body>
<div class="document">
@ -386,79 +424,73 @@ const generateGrnHtml = (inputData = getDummyGrnData()) => {
</div>
</div>
<div class="title-block">
<h2 class="doc-title">Goods receipt note</h2>
<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="meta-bar">
<div class="meta-cell">
<div class="label">Status</div>
<div class="value">${escapeHtml(grn.status || '-')}</div>
<div class="status-pill">${escapeHtml(grn.status || '-')}</div>
</div>
<div>
<div class="label">Date received</div>
<div class="meta-cell">
<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="meta-cell">
<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 class="party-grid">
<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>
${renderAddressBlock(vendor)}
</div>
<div>
<div class="label">Received at</div>
<div class="party-name">${escapeHtml(warehouse.name || '-')}</div>
<div class="party-body">${escapeHtml(warehouseAddress || '-')}</div>
<div class="label">Received At</div>
${renderAddressBlock(location)}
${
location.type
? `<div class="party-line" style="margin-top:6px;">Type: ${escapeHtml(location.type)}</div>`
: ''
}
</div>
</div>
<hr class="divider-light" />
<div class="class-row">
<div class="info-grid">
<div>
<div class="label">Vendor invoice</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="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="label">Vehicle No</div>
<div class="value">${escapeHtml(grn.vehicle_no || '-')}</div>
</div>
<div>
<div class="label">LR no / date</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 class="col-no">#</th>
<th class="col-item">Item description</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-batch">Batch</th>
<th class="col-date">Mfg date</th>
<th class="col-date">Mfg Date</th>
<th class="col-date">Expiry</th>
<th class="col-reason">Reason</th>
</tr>
@ -481,16 +513,22 @@ const generateGrnHtml = (inputData = getDummyGrnData()) => {
<div class="sign-row">
<div>
<div class="sign-title">Received by</div>
<div class="sign-sub">${escapeHtml(grn.received_by || '-')}</div>
<div class="sign-line">
<div class="sign-title">Received By</div>
<div class="sign-sub">${escapeHtml(grn.received_by || '-')}</div>
</div>
</div>
<div>
<div class="sign-title">Quality checked by</div>
<div class="sign-sub">${escapeHtml(grn.quality_checked_by || '-')}</div>
<div class="sign-line">
<div class="sign-title">Quality Checked By</div>
<div class="sign-sub">${escapeHtml(grn.quality_checked_by || '-')}</div>
</div>
</div>
<div>
<div class="sign-title">Authorised signatory</div>
<div class="sign-sub">${escapeHtml(company.name || '')}</div>
<div class="sign-line">
<div class="sign-title">Authorised Signatory</div>
<div class="sign-sub">${escapeHtml(company.name || '')}</div>
</div>
</div>
</div>

View File

@ -2,102 +2,98 @@ const { formatCurrency } = require('../helpers/formatCurrency');
const { formatDate } = require('../helpers/formatDate');
const { numberToWords } = require('../helpers/numberToWords');
const BRAND = '#1e3a5f';
const BRAND_SOFT = '#eef3f8';
const STATUS_BG = '#fde8d0';
const STATUS_FG = '#9a5b16';
const MUTED = '#6b7280';
const LINE = '#e5e7eb';
const getDummyPoData = () => ({
company: {
name: 'Bharat Industries Pvt. Ltd.',
address: 'Plot 14, Guindy Industrial Estate',
name: 'BCPL Chennai',
address: 'Chennai',
city: 'Chennai',
state: 'Tamil Nadu',
pincode: '600032',
gstin: '33AABCB1234D1Z5',
phone: '+91 44 4000 1200',
email: 'procurement@bharatindustries.in',
state: 'TN',
pincode: '665544',
gstin: '29ABCDE1234F1Z5',
phone: '6369084112',
email: 'venba2026@gmail.com',
},
po: {
po_number: 'PO/2026-27/00007',
po_date: '2026-07-07',
expected_delivery_date: '2026-07-21',
status: 'Approved',
vendor_type: 'Raw material',
revision_no: 1,
payment_term: '30 days credit',
delivery_term: 'FOB (freight on board)',
po_number: 'PO/2026-27/00038',
po_date: '2026-07-15',
expected_delivery_date: '2026-06-01',
status: 'Partially Received',
vendor_type: 'Raw Material',
revision_no: 0,
payment_term: 'Net 30 Days',
delivery_term: 'Free on Board',
terms_and_conditions:
'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.',
remarks: null,
},
vendor: {
vendor_name: 'Tata Steel Limited',
gstin: '20AABCT3456A1Z9',
address: 'Bistupur Main Road',
city: 'Jamshedpur',
state: 'Jharkhand',
pincode: '831001',
vendor_name: 'G E Capital Business Process Management Service Limited, Chennai Branch',
gstin: '29ABCDE1234F1Z5',
address: 'B6/5 Police Qrts, Alandur, Raja Street',
city: 'Chennai',
state: 'Tamil Nadu',
pincode: '600117',
},
bill_to: {
name: 'Chennai manufacturing plant',
name: 'Chennai Junior Plants',
gstin: '33AAACT1234A1Z5',
address: 'Guindy Industrial Estate',
address: 'Guindy Industrial Estate, Plot 14',
city: 'Chennai',
state: 'Tamil Nadu',
pincode: '600032',
},
ship_to: {
name: 'Main warehouse',
gstin: '33AAACT1234A1Z5',
address: 'Ambattur Industrial Estate',
city: 'Chennai',
state: 'Tamil Nadu',
pincode: '600058',
name: 'Ahmedabad Warehouse',
gstin: '24AAACT1234A1Z8',
address: 'GIDC Vatva, Phase II',
city: 'Ahmedabad',
state: 'Gujarat',
pincode: '382445',
},
items: [
{
line_no: 1,
item_name: 'TMT steel bars 12mm',
hsn_code: '72142090',
uom: 'MT',
ordered_qty: 25,
rate: 4250,
discount_pct: 2,
gst_rate_pct: 18,
amount: 106250,
item_name: 'Bootle',
hsn_code: '34029099',
uom: 'BTL',
ordered_qty: 500,
rate: 5,
discount_pct: 0,
gst_rate_pct: 12,
amount: 2500,
},
{
line_no: 2,
item_name: 'Cement OPC 53 grade',
hsn_code: '25232930',
uom: 'Bag',
ordered_qty: 500,
rate: 380,
discount_pct: 0,
gst_rate_pct: 28,
amount: 190000,
},
{
line_no: 3,
item_name: 'River sand',
hsn_code: '25051000',
remarks: 'Confirm bag count on delivery',
uom: 'CFT',
ordered_qty: 1200,
rate: 45,
discount_pct: 5,
gst_rate_pct: 5,
amount: 54000,
item_name: 'Raw Materials - 1',
hsn_code: '28289040',
uom: 'KG',
ordered_qty: 1000,
rate: 10,
discount_pct: 10,
gst_rate_pct: 18,
amount: 10000,
},
],
totals: {
sub_total: 119775,
tax_total: 19768.75,
cgst: 9884.38,
sgst: 9884.37,
igst: 0,
freight_charges: 750,
other_charges: 150,
discount_amount: 500,
grand_total: 139943.75,
sub_total: 11500,
freight_charges: 100,
other_charges: 0,
discount_amount: 20,
taxable_amount: 11580,
cgst: 0,
sgst: 0,
igst: 1920,
tax_total: 1920,
grand_total: 13500,
},
generated_at: '2026-07-07 14:32 IST',
generated_at: '17/07/2026 05:17 IST',
});
const escapeHtml = (value = '') =>
@ -126,6 +122,282 @@ const qtyDisplay = (value) => {
return Number.isInteger(num) ? String(num) : num.toFixed(2);
};
/** Multi-line party address block (name + street + city/state/pin + GSTIN). */
const renderAddressBlock = (party = {}, { showGstin = true } = {}) => {
const lines = [];
if (party.name || party.vendor_name) {
lines.push(
`<div class="party-name">${escapeHtml(party.name || party.vendor_name || '-')}</div>`
);
}
if (party.address) {
lines.push(`<div class="party-line">${escapeHtml(party.address)}</div>`);
}
const cityLine = joinParts(party.city, party.state, party.pincode);
if (cityLine) {
lines.push(`<div class="party-line">${escapeHtml(cityLine)}</div>`);
}
if (showGstin && party.gstin) {
lines.push(`<div class="party-line">GSTIN: ${escapeHtml(party.gstin)}</div>`);
}
if (!lines.length) {
lines.push(`<div class="party-line">-</div>`);
}
return lines.join('');
};
const sharedDocumentStyles = () => `
* { box-sizing: border-box; }
body {
margin: 0;
font-family: Helvetica, Arial, sans-serif;
color: #111827;
background: #ffffff;
font-size: 11px;
line-height: 1.45;
}
.document { width: 100%; padding: 0; }
.text-right { text-align: right; }
.label {
color: ${MUTED};
font-size: 9px;
letter-spacing: 0.08em;
text-transform: uppercase;
font-weight: 700;
}
.header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 24px;
}
.brand {
display: flex;
gap: 12px;
align-items: flex-start;
flex: 1;
min-width: 0;
}
.company-logo {
width: 52px;
height: 52px;
object-fit: contain;
flex-shrink: 0;
}
.company-name {
margin: 0;
font-size: 20px;
font-weight: 800;
color: ${BRAND};
letter-spacing: -0.02em;
}
.company-meta {
margin-top: 4px;
color: #4b5563;
font-size: 10.5px;
line-height: 1.5;
}
.title-block { text-align: right; min-width: 210px; }
.doc-title {
margin: 0;
font-size: 26px;
font-weight: 800;
color: ${BRAND};
letter-spacing: -0.02em;
}
.doc-number {
margin-top: 4px;
color: #374151;
font-size: 12px;
font-weight: 600;
}
.meta-bar {
margin-top: 16px;
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 0;
border-top: 1px solid ${LINE};
border-bottom: 1px solid ${LINE};
background: #fafbfc;
}
.meta-cell {
padding: 10px 14px;
border-right: 1px solid ${LINE};
}
.meta-cell:last-child { border-right: 0; }
.meta-cell .value {
margin-top: 4px;
font-size: 12px;
font-weight: 700;
color: #111827;
}
.status-pill {
display: inline-block;
margin-top: 4px;
padding: 3px 10px;
border-radius: 999px;
background: ${STATUS_BG};
color: ${STATUS_FG};
font-size: 11px;
font-weight: 700;
}
.party-grid {
margin-top: 16px;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px 28px;
}
.party-name {
margin-top: 4px;
font-size: 12.5px;
font-weight: 800;
color: #111827;
}
.party-line {
margin-top: 2px;
color: #4b5563;
font-size: 10.5px;
line-height: 1.45;
}
.party-term {
margin-top: 6px;
color: #374151;
font-size: 10.5px;
}
.party-term strong { font-weight: 700; }
table.items {
width: 100%;
border-collapse: collapse;
margin-top: 18px;
}
table.items thead th {
background: ${BRAND};
color: #ffffff;
padding: 9px 6px;
font-size: 9px;
letter-spacing: 0.06em;
text-transform: uppercase;
font-weight: 700;
text-align: left;
}
table.items thead th.text-right { text-align: right; }
table.items tbody td {
padding: 10px 6px;
border-bottom: 1px solid ${LINE};
vertical-align: top;
font-size: 11px;
}
table.items tbody tr:nth-child(even) { background: ${BRAND_SOFT}; }
.col-no { width: 4%; color: #6b7280; }
.col-item { width: 26%; }
.col-hsn { width: 10%; }
.col-qty { width: 7%; }
.col-uom { width: 7%; }
.col-rate { width: 12%; }
.col-disc { width: 7%; }
.col-gst { width: 7%; }
.col-amt { width: 14%; }
.item-name { font-weight: 700; }
.item-note {
margin-top: 2px;
color: #888888;
font-size: 9.5px;
font-style: italic;
}
.summary-wrap {
margin-top: 14px;
display: flex;
justify-content: flex-end;
}
.summary { width: 300px; }
.summary-row {
display: flex;
justify-content: space-between;
gap: 24px;
padding: 4px 0;
color: #4b5563;
font-size: 11px;
}
.summary-row .amount { color: #111827; font-weight: 600; }
.summary-row.discount .amount { color: #c62828; }
.summary-row.taxable {
margin-top: 2px;
padding-top: 6px;
border-top: 1px solid ${LINE};
color: #111827;
font-weight: 700;
}
.grand-row {
display: flex;
justify-content: space-between;
gap: 24px;
margin-top: 8px;
padding: 10px 0 4px;
border-top: 1.5px solid ${BRAND};
font-size: 14px;
font-weight: 800;
color: ${BRAND};
}
.amount-words {
margin-top: 16px;
background: #f3f4f6;
border: 1px solid ${LINE};
padding: 10px 12px;
}
.amount-words .label {
color: ${MUTED};
font-size: 9px;
letter-spacing: 0.08em;
}
.amount-words .words {
margin-top: 3px;
font-weight: 700;
font-size: 12px;
color: #111827;
}
.notes { margin-top: 16px; }
.notes-block { margin-bottom: 10px; }
.notes-block .body {
margin-top: 4px;
color: #4b5563;
font-size: 10.5px;
line-height: 1.5;
white-space: pre-wrap;
}
.sign-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 40px;
margin-top: 36px;
}
.sign-line {
border-top: 1px solid #9ca3af;
margin-top: 48px;
padding-top: 8px;
}
.sign-title { font-size: 11px; font-weight: 700; color: #111827; }
.sign-sub {
margin-top: 2px;
color: #6b7280;
font-size: 10px;
}
.footer {
margin-top: 28px;
text-align: center;
color: #9ca3af;
font-size: 9.5px;
}
`;
const generatePoHtml = (inputData = getDummyPoData()) => {
const data = inputData || getDummyPoData();
const company = data.company || {};
@ -164,12 +436,24 @@ const generatePoHtml = (inputData = getDummyPoData()) => {
.join('');
const discountValue = Number(totals.discount_amount || 0);
const discountFormatted = discountValue > 0 ? `-${formatCurrency(discountValue)}` : formatCurrency(0);
const discountFormatted =
discountValue > 0 ? `-${formatCurrency(discountValue)}` : formatCurrency(0);
const taxableAmount =
totals.taxable_amount != null
? Number(totals.taxable_amount)
: Number(totals.sub_total || 0) +
Number(totals.freight_charges || 0) +
Number(totals.other_charges || 0) -
discountValue;
const cgst = Number(totals.cgst || 0);
const sgst = Number(totals.sgst || 0);
const igst = Number(totals.igst || 0);
const taxRowsHtml =
const tdsAmount = Number(totals.tds_amount || 0);
const adjustmentAmount = Number(totals.adjustment || 0);
const taxBreakdownHtml =
igst > 0
? `
<div class="summary-row">
@ -186,9 +470,27 @@ const generatePoHtml = (inputData = getDummyPoData()) => {
<span class="amount">${formatCurrency(sgst)}</span>
</div>`;
const vendorAddress = joinParts(vendor.address, vendor.city, vendor.state, vendor.pincode);
const billAddress = joinParts(billTo.address, billTo.city, billTo.state, billTo.pincode);
const shipAddress = joinParts(shipTo.address, shipTo.city, shipTo.state, shipTo.pincode);
const optionalChargeRows = [
tdsAmount > 0
? `
<div class="summary-row discount">
<span>TDS</span>
<span class="amount">-${formatCurrency(tdsAmount)}</span>
</div>`
: '',
adjustmentAmount !== 0
? `
<div class="summary-row">
<span>Adjustment</span>
<span class="amount">${
adjustmentAmount < 0
? `-${formatCurrency(Math.abs(adjustmentAmount))}`
: formatCurrency(adjustmentAmount)
}</span>
</div>`
: '',
].join('');
const generatedAt = data.generated_at || formatDate(new Date(), { style: 'datetime' });
return `
@ -197,239 +499,7 @@ const generatePoHtml = (inputData = getDummyPoData()) => {
<head>
<meta charset="UTF-8" />
<title>Purchase Order ${escapeHtml(po.po_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; }
.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: 24px;
}
.brand {
display: flex;
gap: 12px;
align-items: flex-start;
flex: 1;
min-width: 0;
}
.company-logo {
width: 56px;
height: 56px;
object-fit: contain;
flex-shrink: 0;
}
.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: 210px; }
.doc-title {
margin: 0;
font-size: 26px;
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;
}
.party-term {
margin-top: 6px;
color: #444444;
font-size: 10.5px;
}
.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 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;
}
table.items thead th.text-right {
text-align: right;
}
table.items tbody td {
padding: 10px 4px;
border-bottom: 1px solid #e5e5e5;
vertical-align: top;
font-size: 11px;
}
.col-no { width: 4%; color: #666666; }
.col-item { width: 26%; }
.col-hsn { width: 10%; }
.col-qty { width: 7%; }
.col-uom { width: 7%; }
.col-rate { width: 12%; }
.col-disc { width: 7%; }
.col-gst { width: 7%; }
.col-amt { width: 14%; }
.item-name { font-weight: 600; }
.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;
gap: 28px;
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>
<style>${sharedDocumentStyles()}</style>
</head>
<body>
<div class="document">
@ -449,90 +519,62 @@ const generatePoHtml = (inputData = getDummyPoData()) => {
</div>
</div>
<div class="title-block">
<h2 class="doc-title">Purchase order</h2>
<h2 class="doc-title">Purchase Order</h2>
<div class="doc-number">${escapeHtml(po.po_number || '-')}</div>
</div>
</div>
<hr class="divider" />
<div class="meta-row">
<div>
<div class="meta-bar">
<div class="meta-cell">
<div class="label">Status</div>
<div class="value">${escapeHtml(po.status || '-')}</div>
<div class="status-pill">${escapeHtml(po.status || '-')}</div>
</div>
<div>
<div class="label">Date issued</div>
<div class="meta-cell">
<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="meta-cell">
<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 class="party-grid">
<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>
${renderAddressBlock(vendor)}
${
po.payment_term
? `<div class="party-term">Payment term: ${escapeHtml(po.payment_term)}</div>`
? `<div class="party-term"><strong>Payment term:</strong> ${escapeHtml(po.payment_term)}</div>`
: ''
}
</div>
<div>
<div class="label">Bill to</div>
<div class="party-name">${escapeHtml(billTo.name || '-')}</div>
<div class="party-body">
${escapeHtml(billAddress || '-')}<br />
${billTo.gstin ? `GSTIN: ${escapeHtml(billTo.gstin)}` : ''}
</div>
<div class="label">Bill To</div>
${renderAddressBlock(billTo)}
</div>
</div>
<hr class="divider-light" />
<div class="party-row">
<div>
<div class="label">Ship to</div>
<div class="party-name">${escapeHtml(shipTo.name || '-')}</div>
<div class="party-body">
${escapeHtml(shipAddress || '-')}<br />
${shipTo.gstin ? `GSTIN: ${escapeHtml(shipTo.gstin)}` : ''}
</div>
<div class="label">Ship To</div>
${renderAddressBlock(shipTo)}
${
po.delivery_term
? `<div class="party-term">Delivery term: ${escapeHtml(po.delivery_term)}</div>`
? `<div class="party-term"><strong>Delivery term:</strong> ${escapeHtml(po.delivery_term)}</div>`
: ''
}
</div>
</div>
<hr class="divider-light" />
<div class="class-row">
<div>
<div class="label">Vendor type</div>
<div class="value">${escapeHtml(po.vendor_type || '-')}</div>
</div>
<div>
<div class="label">Revision</div>
<div class="value">${escapeHtml(String(po.revision_no ?? 0))}</div>
<div class="label">Vendor Type</div>
<div class="party-name" style="font-size:12px;">${escapeHtml(po.vendor_type || '-')}</div>
<div class="label" style="margin-top:12px;">Revision</div>
<div class="party-name" style="font-size:12px;">${escapeHtml(String(po.revision_no ?? 0))}</div>
</div>
</div>
<hr class="divider-light" />
<table class="items">
<thead>
<tr>
<th class="col-no">#</th>
<th class="col-item">Item description</th>
<th class="col-item">Item Description</th>
<th class="col-hsn">HSN</th>
<th class="col-qty text-right">Qty</th>
<th class="col-uom">UOM</th>
@ -550,42 +592,48 @@ const generatePoHtml = (inputData = getDummyPoData()) => {
<div class="summary-wrap">
<div class="summary">
<div class="summary-row">
<span>Taxable amount</span>
<span>Sub Total</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>
${taxRowsHtml}
<div class="summary-row">
<span>Freight charges</span>
<span>Freight Charges</span>
<span class="amount">${formatCurrency(totals.freight_charges)}</span>
</div>
<div class="summary-row">
<span>Other charges</span>
<span>Other Charges</span>
<span class="amount">${formatCurrency(totals.other_charges)}</span>
</div>
<div class="summary-row">
<span>Discount</span>
<div class="summary-row discount">
<span>Discount Amount</span>
<span class="amount">${discountFormatted}</span>
</div>
<div class="summary-row taxable">
<span>Taxable Amount</span>
<span class="amount">${formatCurrency(taxableAmount)}</span>
</div>
${taxBreakdownHtml}
<div class="summary-row">
<span>Tax Total</span>
<span class="amount">${formatCurrency(totals.tax_total)}</span>
</div>
${optionalChargeRows}
<div class="grand-row">
<span>Grand total</span>
<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">
<div class="label">Amount In Words</div>
<div class="words">${escapeHtml(numberToWords(totals.grand_total))}</div>
</div>
<div class="notes">
${
po.terms_and_conditions
? `<div class="notes-block">
<div class="label">Terms and conditions</div>
<div class="label">Terms And Conditions</div>
<div class="body">${escapeHtml(po.terms_and_conditions)}</div>
</div>`
: ''
@ -602,12 +650,16 @@ const generatePoHtml = (inputData = getDummyPoData()) => {
<div class="sign-row">
<div>
<div class="sign-title">Authorised signatory</div>
<div class="sign-sub">${escapeHtml(company.name || '')}</div>
<div class="sign-line">
<div class="sign-title">Authorised Signatory</div>
<div class="sign-sub">${escapeHtml(company.name || '')}</div>
</div>
</div>
<div>
<div class="sign-title">Accepted by vendor</div>
<div class="sign-sub">${escapeHtml(vendor.vendor_name || '')}</div>
<div class="sign-line">
<div class="sign-title">Accepted By Vendor</div>
<div class="sign-sub">${escapeHtml(vendor.vendor_name || '')}</div>
</div>
</div>
</div>