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

View File

@ -31,25 +31,26 @@ const computeHeaderTotals = (
other = 0, other = 0,
{ tdsApplicable = false, tdsSectionPct = 0, adjustment = 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 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 taxTotal = round4(lines.reduce((sum, line) => sum + toNum(line.tax_amount), 0));
const headerDiscountAmount = round4(headerDiscount); const headerDiscountAmount = round4(headerDiscount);
const freightCharges = round4(freight); const freightCharges = round4(freight);
const otherCharges = round4(other); 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 tdsAmount = tdsApplicable ? round4((subTotal * toNum(tdsSectionPct)) / 100) : 0;
const adjustmentAmount = round4(adjustment); const adjustmentAmount = round4(adjustment);
const grandTotal = round4(
subTotal + // Grand total = Taxable Amount + Tax Total TDS + Adjustment.
taxTotal + const grandTotal = round4(taxableAmount + taxTotal - tdsAmount + adjustmentAmount);
freightCharges +
otherCharges -
headerDiscountAmount -
tdsAmount +
adjustmentAmount
);
return { return {
sub_total: subTotal, sub_total: subTotal,
taxable_amount: taxableAmount,
tax_total: taxTotal, tax_total: taxTotal,
discount_amount: headerDiscountAmount, discount_amount: headerDiscountAmount,
freight_charges: freightCharges, freight_charges: freightCharges,

View File

@ -21,6 +21,7 @@ const {
computeHeaderTotals, computeHeaderTotals,
splitGst, splitGst,
toNum, toNum,
round4,
} = require('./purchase-orders.calculations'); } = require('./purchase-orders.calculations');
const repository = require('./purchase-orders.repository'); const repository = require('./purchase-orders.repository');
const { assertAnyLocation } = require('../../utils/locations'); const { assertAnyLocation } = require('../../utils/locations');
@ -143,6 +144,13 @@ const sanitizePo = (po) => {
return { return {
...rest, ...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, vendor: vendors || null,
billing: billing_location || null, billing: billing_location || null,
shipping: shipping_location || null, shipping: shipping_location || null,
@ -312,8 +320,10 @@ const buildHeaderData = async (payload, builtItems, userId, { applyDefaultTerms
adjustment: payload.adjustment, 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 = let termsAndConditions =
payload.terms_and_conditions !== undefined && payload.terms_and_conditions !== null 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, remarks: payload.remarks || null,
tds_applicable: tdsApplicable, tds_applicable: tdsApplicable,
tds_section_pct: tdsApplicable ? tdsSectionPct : null, tds_section_pct: tdsApplicable ? tdsSectionPct : null,
...totals, ...persistedTotals,
...gstSplit, ...gstSplit,
updated_by: userId ? BigInt(userId) : null, updated_by: userId ? BigInt(userId) : null,
}; };
@ -436,18 +446,21 @@ const buildPoPdfPayload = (po, company) => {
rate, rate,
discount_pct: toNum(line.discount_pct), discount_pct: toNum(line.discount_pct),
gst_rate_pct: toNum(line.gst_rate?.rate_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: { totals: {
sub_total: toNum(po.sub_total), 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), freight_charges: toNum(po.freight_charges),
other_charges: toNum(po.other_charges), other_charges: toNum(po.other_charges),
discount_amount: toNum(po.discount_amount), 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), grand_total: toNum(po.grand_total),
}, },
generated_at: `${formatDate(new Date(), { style: 'datetime' })} IST`, generated_at: `${formatDate(new Date(), { style: 'datetime' })} IST`,

View File

@ -1,21 +1,28 @@
const { formatDate } = require('../helpers/formatDate'); 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 = () => ({ const getDummyGrnData = () => ({
company: { company: {
name: 'Bharat Industries Pvt. Ltd.', name: 'BCPL Chennai',
address: 'Plot 14, Guindy Industrial Estate', address: 'Chennai',
city: 'Chennai', city: 'Chennai',
state: 'Tamil Nadu', state: 'TN',
pincode: '600032', pincode: '665544',
gstin: '33AABCB1234D1Z5', gstin: '29ABCDE1234F1Z5',
phone: '+91 44 4000 1200', phone: '6369084112',
email: 'procurement@bharatindustries.in', email: 'venba2026@gmail.com',
}, },
grn: { grn: {
grn_number: 'GRN/2026-27/00012', grn_number: 'GRN/2026-27/00012',
grn_date: '2026-07-14', grn_date: '2026-07-14',
status: 'Posted', status: 'Posted',
po_number: 'PO/2026-27/00007', po_number: 'PO/2026-27/00038',
vendor_invoice_no: 'TSL/INV/2026/441', vendor_invoice_no: 'TSL/INV/2026/441',
vendor_invoice_date: '2026-07-13', vendor_invoice_date: '2026-07-13',
vehicle_no: 'TN-09-AB-4521', vehicle_no: 'TN-09-AB-4521',
@ -26,31 +33,33 @@ const getDummyGrnData = () => ({
remarks: 'All items inspected and accepted except partial rejection on line 3.', remarks: 'All items inspected and accepted except partial rejection on line 3.',
}, },
vendor: { vendor: {
vendor_name: 'Tata Steel Limited', vendor_name: 'G E Capital Business Process Management Service Limited, Chennai Branch',
gstin: '20AABCT3456A1Z9', gstin: '29ABCDE1234F1Z5',
address: 'Bistupur Main Road', address: 'B6/5 Police Qrts, Alandur, Raja Street',
city: 'Jamshedpur',
state: 'Jharkhand',
pincode: '831001',
},
warehouse: {
name: 'Main warehouse',
address: 'Guindy Industrial Estate',
city: 'Chennai', city: 'Chennai',
state: 'Tamil Nadu', 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: [ items: [
{ {
line_no: 1, line_no: 1,
item_name: 'TMT steel bars 12mm', item_name: 'Bootle',
item_code: 'ITM-TMT-12', item_code: 'ITM-BTL-01',
uom: 'MT', uom: 'BTL',
ordered_qty: 25, ordered_qty: 500,
current_qty: 25, current_qty: 500,
accepted_qty: 25, accepted_qty: 500,
rejected_qty: 0, rejected_qty: 0,
batch_no: 'BAT-TMT-2407', batch_no: 'BAT-BTL-01',
mfg_date: '2026-06-01', mfg_date: '2026-06-01',
expiry_date: null, expiry_date: null,
rejection_reason: null, rejection_reason: null,
@ -59,38 +68,22 @@ const getDummyGrnData = () => ({
}, },
{ {
line_no: 2, line_no: 2,
item_name: 'Cement OPC 53 grade', item_name: 'Raw Materials - 1',
item_code: 'ITM-CEM-53', item_code: 'ITM-RM-01',
uom: 'Bag', uom: 'KG',
ordered_qty: 500, ordered_qty: 1000,
current_qty: 500, current_qty: 980,
accepted_qty: 500, accepted_qty: 960,
rejected_qty: 0, rejected_qty: 20,
batch_no: 'CEM-JUN-26', batch_no: 'RM-JUL-26',
mfg_date: '2026-05-15', mfg_date: '2026-05-15',
expiry_date: '2027-05-15', expiry_date: '2027-05-15',
rejection_reason: null, rejection_reason: 'Damaged packaging',
storage_location: 'Shed B', storage_location: 'Shed B',
remarks: null, remarks: 'Confirm count on delivery',
},
{
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',
}, },
], ],
generated_at: '14/07/2026 09:40', generated_at: '14/07/2026 09:40 IST',
}); });
const escapeHtml = (value = '') => const escapeHtml = (value = '') =>
@ -124,12 +117,247 @@ const displayOrDash = (value) => {
return String(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 generateGrnHtml = (inputData = getDummyGrnData()) => {
const data = inputData || getDummyGrnData(); const data = inputData || getDummyGrnData();
const company = data.company || {}; const company = data.company || {};
const grn = data.grn || {}; const grn = data.grn || {};
const vendor = data.vendor || {}; const vendor = data.vendor || {};
const warehouse = data.warehouse || {}; const location = data.location || data.warehouse || {};
const itemsRows = (data.items || []) const itemsRows = (data.items || [])
.map((item, index) => { .map((item, index) => {
@ -165,13 +393,6 @@ const generateGrnHtml = (inputData = getDummyGrnData()) => {
}) })
.join(''); .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 generatedAt = data.generated_at || formatDate(new Date(), { style: 'datetime' });
const lrLine = [grn.lr_no, grn.lr_date ? formatDate(grn.lr_date, { style: 'numeric' }) : null] const lrLine = [grn.lr_no, grn.lr_date ? formatDate(grn.lr_date, { style: 'numeric' }) : null]
.filter(Boolean) .filter(Boolean)
@ -183,190 +404,7 @@ const generateGrnHtml = (inputData = getDummyGrnData()) => {
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<title>Goods Receipt Note ${escapeHtml(grn.grn_number || '')}</title> <title>Goods Receipt Note ${escapeHtml(grn.grn_number || '')}</title>
<style> <style>${sharedDocumentStyles()}</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>
</head> </head>
<body> <body>
<div class="document"> <div class="document">
@ -386,79 +424,73 @@ const generateGrnHtml = (inputData = getDummyGrnData()) => {
</div> </div>
</div> </div>
<div class="title-block"> <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 class="doc-number">${escapeHtml(grn.grn_number || '-')}</div>
</div> </div>
</div> </div>
<hr class="divider" /> <div class="meta-bar">
<div class="meta-cell">
<div class="meta-row">
<div>
<div class="label">Status</div> <div class="label">Status</div>
<div class="value">${escapeHtml(grn.status || '-')}</div> <div class="status-pill">${escapeHtml(grn.status || '-')}</div>
</div> </div>
<div> <div class="meta-cell">
<div class="label">Date received</div> <div class="label">Date Received</div>
<div class="value">${formatDate(grn.grn_date, { style: 'numeric' })}</div> <div class="value">${formatDate(grn.grn_date, { style: 'numeric' })}</div>
</div> </div>
<div> <div class="meta-cell">
<div class="label">PO reference</div> <div class="label">PO Reference</div>
<div class="value">${escapeHtml(grn.po_number || '-')}</div> <div class="value">${escapeHtml(grn.po_number || '-')}</div>
</div> </div>
</div> </div>
<div class="party-row" style="margin-top: 18px;"> <div class="party-grid">
<div> <div>
<div class="label">Vendor</div> <div class="label">Vendor</div>
<div class="party-name">${escapeHtml(vendor.vendor_name || '-')}</div> ${renderAddressBlock(vendor)}
<div class="party-body">
${escapeHtml(vendorAddress || '-')}<br />
${vendor.gstin ? `GSTIN: ${escapeHtml(vendor.gstin)}` : ''}
</div>
</div> </div>
<div> <div>
<div class="label">Received at</div> <div class="label">Received At</div>
<div class="party-name">${escapeHtml(warehouse.name || '-')}</div> ${renderAddressBlock(location)}
<div class="party-body">${escapeHtml(warehouseAddress || '-')}</div> ${
location.type
? `<div class="party-line" style="margin-top:6px;">Type: ${escapeHtml(location.type)}</div>`
: ''
}
</div> </div>
</div> </div>
<hr class="divider-light" /> <div class="info-grid">
<div class="class-row">
<div> <div>
<div class="label">Vendor invoice</div> <div class="label">Vendor Invoice</div>
<div class="value">${escapeHtml(grn.vendor_invoice_no || '-')}</div> <div class="value">${escapeHtml(grn.vendor_invoice_no || '-')}</div>
</div> </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 class="value">${formatDate(grn.vendor_invoice_date, { style: 'numeric' })}</div>
</div> </div>
<div> <div>
<div class="label">Vehicle no</div> <div class="label">Vehicle No</div>
<div class="value">${escapeHtml(grn.vehicle_no || '-')}</div> <div class="value">${escapeHtml(grn.vehicle_no || '-')}</div>
</div> </div>
<div> <div>
<div class="label">LR no / date</div> <div class="label">LR No / Date</div>
<div class="value">${escapeHtml(lrLine || '-')}</div> <div class="value">${escapeHtml(lrLine || '-')}</div>
</div> </div>
</div> </div>
<hr class="divider-light" />
<table class="items"> <table class="items">
<thead> <thead>
<tr> <tr>
<th class="col-no">#</th> <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">Ordered</th>
<th class="col-qty text-right">Received</th> <th class="col-qty text-right">Received</th>
<th class="col-qty text-right">Accepted</th> <th class="col-qty text-right">Accepted</th>
<th class="col-qty text-right">Rejected</th> <th class="col-qty text-right">Rejected</th>
<th class="col-uom">UOM</th> <th class="col-uom">UOM</th>
<th class="col-batch">Batch</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-date">Expiry</th>
<th class="col-reason">Reason</th> <th class="col-reason">Reason</th>
</tr> </tr>
@ -481,16 +513,22 @@ const generateGrnHtml = (inputData = getDummyGrnData()) => {
<div class="sign-row"> <div class="sign-row">
<div> <div>
<div class="sign-title">Received by</div> <div class="sign-line">
<div class="sign-sub">${escapeHtml(grn.received_by || '-')}</div> <div class="sign-title">Received By</div>
<div class="sign-sub">${escapeHtml(grn.received_by || '-')}</div>
</div>
</div> </div>
<div> <div>
<div class="sign-title">Quality checked by</div> <div class="sign-line">
<div class="sign-sub">${escapeHtml(grn.quality_checked_by || '-')}</div> <div class="sign-title">Quality Checked By</div>
<div class="sign-sub">${escapeHtml(grn.quality_checked_by || '-')}</div>
</div>
</div> </div>
<div> <div>
<div class="sign-title">Authorised signatory</div> <div class="sign-line">
<div class="sign-sub">${escapeHtml(company.name || '')}</div> <div class="sign-title">Authorised Signatory</div>
<div class="sign-sub">${escapeHtml(company.name || '')}</div>
</div>
</div> </div>
</div> </div>

View File

@ -2,102 +2,98 @@ const { formatCurrency } = require('../helpers/formatCurrency');
const { formatDate } = require('../helpers/formatDate'); const { formatDate } = require('../helpers/formatDate');
const { numberToWords } = require('../helpers/numberToWords'); 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 = () => ({ const getDummyPoData = () => ({
company: { company: {
name: 'Bharat Industries Pvt. Ltd.', name: 'BCPL Chennai',
address: 'Plot 14, Guindy Industrial Estate', address: 'Chennai',
city: 'Chennai', city: 'Chennai',
state: 'Tamil Nadu', state: 'TN',
pincode: '600032', pincode: '665544',
gstin: '33AABCB1234D1Z5', gstin: '29ABCDE1234F1Z5',
phone: '+91 44 4000 1200', phone: '6369084112',
email: 'procurement@bharatindustries.in', email: 'venba2026@gmail.com',
}, },
po: { po: {
po_number: 'PO/2026-27/00007', po_number: 'PO/2026-27/00038',
po_date: '2026-07-07', po_date: '2026-07-15',
expected_delivery_date: '2026-07-21', expected_delivery_date: '2026-06-01',
status: 'Approved', status: 'Partially Received',
vendor_type: 'Raw material', vendor_type: 'Raw Material',
revision_no: 1, revision_no: 0,
payment_term: '30 days credit', payment_term: 'Net 30 Days',
delivery_term: 'FOB (freight on board)', delivery_term: 'Free on Board',
terms_and_conditions: terms_and_conditions:
'Payment as per agreed terms. Goods must match PO specification. Shortages or damages must be reported within 48 hours of delivery.', '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: {
vendor_name: 'Tata Steel Limited', vendor_name: 'G E Capital Business Process Management Service Limited, Chennai Branch',
gstin: '20AABCT3456A1Z9', gstin: '29ABCDE1234F1Z5',
address: 'Bistupur Main Road', address: 'B6/5 Police Qrts, Alandur, Raja Street',
city: 'Jamshedpur', city: 'Chennai',
state: 'Jharkhand', state: 'Tamil Nadu',
pincode: '831001', pincode: '600117',
}, },
bill_to: { bill_to: {
name: 'Chennai manufacturing plant', name: 'Chennai Junior Plants',
gstin: '33AAACT1234A1Z5', gstin: '33AAACT1234A1Z5',
address: 'Guindy Industrial Estate', address: 'Guindy Industrial Estate, Plot 14',
city: 'Chennai', city: 'Chennai',
state: 'Tamil Nadu', state: 'Tamil Nadu',
pincode: '600032', pincode: '600032',
}, },
ship_to: { ship_to: {
name: 'Main warehouse', name: 'Ahmedabad Warehouse',
gstin: '33AAACT1234A1Z5', gstin: '24AAACT1234A1Z8',
address: 'Ambattur Industrial Estate', address: 'GIDC Vatva, Phase II',
city: 'Chennai', city: 'Ahmedabad',
state: 'Tamil Nadu', state: 'Gujarat',
pincode: '600058', pincode: '382445',
}, },
items: [ items: [
{ {
line_no: 1, line_no: 1,
item_name: 'TMT steel bars 12mm', item_name: 'Bootle',
hsn_code: '72142090', hsn_code: '34029099',
uom: 'MT', uom: 'BTL',
ordered_qty: 25, ordered_qty: 500,
rate: 4250, rate: 5,
discount_pct: 2, discount_pct: 0,
gst_rate_pct: 18, gst_rate_pct: 12,
amount: 106250, amount: 2500,
}, },
{ {
line_no: 2, line_no: 2,
item_name: 'Cement OPC 53 grade', item_name: 'Raw Materials - 1',
hsn_code: '25232930', hsn_code: '28289040',
uom: 'Bag', uom: 'KG',
ordered_qty: 500, ordered_qty: 1000,
rate: 380, rate: 10,
discount_pct: 0, discount_pct: 10,
gst_rate_pct: 28, gst_rate_pct: 18,
amount: 190000, amount: 10000,
},
{
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,
}, },
], ],
totals: { totals: {
sub_total: 119775, sub_total: 11500,
tax_total: 19768.75, freight_charges: 100,
cgst: 9884.38, other_charges: 0,
sgst: 9884.37, discount_amount: 20,
igst: 0, taxable_amount: 11580,
freight_charges: 750, cgst: 0,
other_charges: 150, sgst: 0,
discount_amount: 500, igst: 1920,
grand_total: 139943.75, 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 = '') => const escapeHtml = (value = '') =>
@ -126,6 +122,282 @@ const qtyDisplay = (value) => {
return Number.isInteger(num) ? String(num) : num.toFixed(2); 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 generatePoHtml = (inputData = getDummyPoData()) => {
const data = inputData || getDummyPoData(); const data = inputData || getDummyPoData();
const company = data.company || {}; const company = data.company || {};
@ -164,12 +436,24 @@ const generatePoHtml = (inputData = getDummyPoData()) => {
.join(''); .join('');
const discountValue = Number(totals.discount_amount || 0); 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 cgst = Number(totals.cgst || 0);
const sgst = Number(totals.sgst || 0); const sgst = Number(totals.sgst || 0);
const igst = Number(totals.igst || 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 igst > 0
? ` ? `
<div class="summary-row"> <div class="summary-row">
@ -186,9 +470,27 @@ const generatePoHtml = (inputData = getDummyPoData()) => {
<span class="amount">${formatCurrency(sgst)}</span> <span class="amount">${formatCurrency(sgst)}</span>
</div>`; </div>`;
const vendorAddress = joinParts(vendor.address, vendor.city, vendor.state, vendor.pincode); const optionalChargeRows = [
const billAddress = joinParts(billTo.address, billTo.city, billTo.state, billTo.pincode); tdsAmount > 0
const shipAddress = joinParts(shipTo.address, shipTo.city, shipTo.state, shipTo.pincode); ? `
<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' }); const generatedAt = data.generated_at || formatDate(new Date(), { style: 'datetime' });
return ` return `
@ -197,239 +499,7 @@ const generatePoHtml = (inputData = getDummyPoData()) => {
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<title>Purchase Order ${escapeHtml(po.po_number || '')}</title> <title>Purchase Order ${escapeHtml(po.po_number || '')}</title>
<style> <style>${sharedDocumentStyles()}</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>
</head> </head>
<body> <body>
<div class="document"> <div class="document">
@ -449,90 +519,62 @@ const generatePoHtml = (inputData = getDummyPoData()) => {
</div> </div>
</div> </div>
<div class="title-block"> <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 class="doc-number">${escapeHtml(po.po_number || '-')}</div>
</div> </div>
</div> </div>
<hr class="divider" /> <div class="meta-bar">
<div class="meta-cell">
<div class="meta-row">
<div>
<div class="label">Status</div> <div class="label">Status</div>
<div class="value">${escapeHtml(po.status || '-')}</div> <div class="status-pill">${escapeHtml(po.status || '-')}</div>
</div> </div>
<div> <div class="meta-cell">
<div class="label">Date issued</div> <div class="label">Date Issued</div>
<div class="value">${formatDate(po.po_date, { style: 'numeric' })}</div> <div class="value">${formatDate(po.po_date, { style: 'numeric' })}</div>
</div> </div>
<div> <div class="meta-cell">
<div class="label">Expected delivery</div> <div class="label">Expected Delivery</div>
<div class="value">${formatDate(po.expected_delivery_date, { style: 'numeric' })}</div> <div class="value">${formatDate(po.expected_delivery_date, { style: 'numeric' })}</div>
</div> </div>
</div> </div>
<div class="party-row" style="margin-top: 18px;"> <div class="party-grid">
<div> <div>
<div class="label">Vendor</div> <div class="label">Vendor</div>
<div class="party-name">${escapeHtml(vendor.vendor_name || '-')}</div> ${renderAddressBlock(vendor)}
<div class="party-body">
${escapeHtml(vendorAddress || '-')}<br />
${vendor.gstin ? `GSTIN: ${escapeHtml(vendor.gstin)}` : ''}
</div>
${ ${
po.payment_term 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> <div>
<div class="label">Bill to</div> <div class="label">Bill To</div>
<div class="party-name">${escapeHtml(billTo.name || '-')}</div> ${renderAddressBlock(billTo)}
<div class="party-body">
${escapeHtml(billAddress || '-')}<br />
${billTo.gstin ? `GSTIN: ${escapeHtml(billTo.gstin)}` : ''}
</div>
</div> </div>
</div>
<hr class="divider-light" />
<div class="party-row">
<div> <div>
<div class="label">Ship to</div> <div class="label">Ship To</div>
<div class="party-name">${escapeHtml(shipTo.name || '-')}</div> ${renderAddressBlock(shipTo)}
<div class="party-body">
${escapeHtml(shipAddress || '-')}<br />
${shipTo.gstin ? `GSTIN: ${escapeHtml(shipTo.gstin)}` : ''}
</div>
${ ${
po.delivery_term 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>
</div>
<hr class="divider-light" />
<div class="class-row">
<div> <div>
<div class="label">Vendor type</div> <div class="label">Vendor Type</div>
<div class="value">${escapeHtml(po.vendor_type || '-')}</div> <div class="party-name" style="font-size:12px;">${escapeHtml(po.vendor_type || '-')}</div>
</div> <div class="label" style="margin-top:12px;">Revision</div>
<div> <div class="party-name" style="font-size:12px;">${escapeHtml(String(po.revision_no ?? 0))}</div>
<div class="label">Revision</div>
<div class="value">${escapeHtml(String(po.revision_no ?? 0))}</div>
</div> </div>
</div> </div>
<hr class="divider-light" />
<table class="items"> <table class="items">
<thead> <thead>
<tr> <tr>
<th class="col-no">#</th> <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-hsn">HSN</th>
<th class="col-qty text-right">Qty</th> <th class="col-qty text-right">Qty</th>
<th class="col-uom">UOM</th> <th class="col-uom">UOM</th>
@ -550,42 +592,48 @@ const generatePoHtml = (inputData = getDummyPoData()) => {
<div class="summary-wrap"> <div class="summary-wrap">
<div class="summary"> <div class="summary">
<div class="summary-row"> <div class="summary-row">
<span>Taxable amount</span> <span>Sub Total</span>
<span class="amount">${formatCurrency(totals.sub_total)}</span> <span class="amount">${formatCurrency(totals.sub_total)}</span>
</div> </div>
<div class="summary-row"> <div class="summary-row">
<span>Tax (GST)</span> <span>Freight Charges</span>
<span class="amount">${formatCurrency(totals.tax_total)}</span>
</div>
${taxRowsHtml}
<div class="summary-row">
<span>Freight charges</span>
<span class="amount">${formatCurrency(totals.freight_charges)}</span> <span class="amount">${formatCurrency(totals.freight_charges)}</span>
</div> </div>
<div class="summary-row"> <div class="summary-row">
<span>Other charges</span> <span>Other Charges</span>
<span class="amount">${formatCurrency(totals.other_charges)}</span> <span class="amount">${formatCurrency(totals.other_charges)}</span>
</div> </div>
<div class="summary-row"> <div class="summary-row discount">
<span>Discount</span> <span>Discount Amount</span>
<span class="amount">${discountFormatted}</span> <span class="amount">${discountFormatted}</span>
</div> </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"> <div class="grand-row">
<span>Grand total</span> <span>Grand Total</span>
<span>${formatCurrency(totals.grand_total)}</span> <span>${formatCurrency(totals.grand_total)}</span>
</div> </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> </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"> <div class="notes">
${ ${
po.terms_and_conditions po.terms_and_conditions
? `<div class="notes-block"> ? `<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 class="body">${escapeHtml(po.terms_and_conditions)}</div>
</div>` </div>`
: '' : ''
@ -602,12 +650,16 @@ const generatePoHtml = (inputData = getDummyPoData()) => {
<div class="sign-row"> <div class="sign-row">
<div> <div>
<div class="sign-title">Authorised signatory</div> <div class="sign-line">
<div class="sign-sub">${escapeHtml(company.name || '')}</div> <div class="sign-title">Authorised Signatory</div>
<div class="sign-sub">${escapeHtml(company.name || '')}</div>
</div>
</div> </div>
<div> <div>
<div class="sign-title">Accepted by vendor</div> <div class="sign-line">
<div class="sign-sub">${escapeHtml(vendor.vendor_name || '')}</div> <div class="sign-title">Accepted By Vendor</div>
<div class="sign-sub">${escapeHtml(vendor.vendor_name || '')}</div>
</div>
</div> </div>
</div> </div>