PO Re-Design
This commit is contained in:
parent
c7543d2e3b
commit
3590fbb39f
@ -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`,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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`,
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user