| 1 |
(function () { |
| 2 |
'use strict'; |
| 3 |
|
| 4 |
function fmt(sym, n) { return sym + Number(n || 0).toFixed(2); } |
| 5 |
function uid() { return '_' + Math.random().toString(36).slice(2); } |
| 6 |
|
| 7 |
/* ── typography CSS-var helper ──────────────────────── */ |
| 8 |
var _typoKeys = { |
| 9 |
family:'font-family', weight:'font-weight', style:'font-style', |
| 10 |
decoration:'text-decoration', transform:'text-transform', |
| 11 |
sizeDesktop:'font-size-d', sizeTablet:'font-size-t', sizeMobile:'font-size-m', |
| 12 |
lineHeightDesktop:'line-height-d', lineHeightTablet:'line-height-t', lineHeightMobile:'line-height-m', |
| 13 |
letterSpacingDesktop:'letter-spacing-d', letterSpacingTablet:'letter-spacing-t', letterSpacingMobile:'letter-spacing-m', |
| 14 |
wordSpacingDesktop:'word-spacing-d', wordSpacingTablet:'word-spacing-t', wordSpacingMobile:'word-spacing-m' |
| 15 |
}; |
| 16 |
function typoCssVarsForEl(el, obj, prefix) { |
| 17 |
if (!obj || typeof obj !== 'object') return; |
| 18 |
Object.keys(_typoKeys).forEach(function (k) { |
| 19 |
var v = obj[k]; |
| 20 |
if (v === undefined || v === '' || v === null) return; |
| 21 |
if (k === 'sizeDesktop' || k === 'sizeTablet' || k === 'sizeMobile') v = v + (obj.sizeUnit || 'px'); |
| 22 |
else if (k === 'lineHeightDesktop' || k === 'lineHeightTablet' || k === 'lineHeightMobile') v = v + (obj.lineHeightUnit || ''); |
| 23 |
else if (k === 'letterSpacingDesktop' || k === 'letterSpacingTablet' || k === 'letterSpacingMobile') v = v + (obj.letterSpacingUnit || 'px'); |
| 24 |
else if (k === 'wordSpacingDesktop' || k === 'wordSpacingTablet' || k === 'wordSpacingMobile') v = v + (obj.wordSpacingUnit || 'px'); |
| 25 |
el.style.setProperty(prefix + _typoKeys[k], String(v)); |
| 26 |
}); |
| 27 |
} |
| 28 |
|
| 29 |
function initInvoice(app) { |
| 30 |
var opts = {}; |
| 31 |
try { opts = JSON.parse(app.getAttribute('data-opts') || '{}'); } catch(e) {} |
| 32 |
|
| 33 |
var accent = opts.accentColor || '#6c3fb5'; |
| 34 |
var cardBg = opts.cardBg || '#ffffff'; |
| 35 |
var headerBg = opts.headerBg || '#f5f3ff'; |
| 36 |
var headerColor = opts.headerColor || '#6c3fb5'; |
| 37 |
var rowOddBg = opts.rowOddBg || '#ffffff'; |
| 38 |
var rowEvenBg = opts.rowEvenBg || '#f9fafb'; |
| 39 |
var totalBg = opts.totalBg || '#6c3fb5'; |
| 40 |
var totalColor = opts.totalColor || '#ffffff'; |
| 41 |
var borderColor = opts.borderColor || '#e5e7eb'; |
| 42 |
var labelColor = opts.labelColor || '#374151'; |
| 43 |
var titleColor = opts.titleColor || '#1f2937'; |
| 44 |
var subtitleColor= opts.subtitleColor || '#6b7280'; |
| 45 |
var sectionBg = opts.sectionBg || ''; |
| 46 |
var maxWidth = opts.maxWidth || 640; |
| 47 |
var cardRadius = opts.cardRadius || 16; |
| 48 |
var inputRadius = opts.inputRadius || 6; |
| 49 |
var titleSize = opts.titleSize || 28; |
| 50 |
var padTop = opts.paddingTop || 60; |
| 51 |
var padBot = opts.paddingBottom || 60; |
| 52 |
var sym = opts.currencySymbol || '$'; |
| 53 |
var showTitle = opts.showTitle !== false; |
| 54 |
var showSub = opts.showSubtitle !== false; |
| 55 |
var showFromTo = opts.showFromTo || false; |
| 56 |
var showInvNo = opts.showInvoiceNo || false; |
| 57 |
|
| 58 |
var items = [ |
| 59 |
{ id: uid(), desc: 'Website Design', qty: 1, price: 1200 }, |
| 60 |
{ id: uid(), desc: 'SEO Optimization', qty: 3, price: 150 }, |
| 61 |
{ id: uid(), desc: 'Monthly Support', qty: 12, price: 80 } |
| 62 |
]; |
| 63 |
var taxRate = parseFloat(opts.defaultTax) || 0; |
| 64 |
var discRate = parseFloat(opts.defaultDiscount) || 0; |
| 65 |
|
| 66 |
// Layout |
| 67 |
app.style.fontFamily = '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif'; |
| 68 |
if (sectionBg) app.style.background = sectionBg; |
| 69 |
app.style.paddingTop = padTop + 'px'; |
| 70 |
app.style.paddingBottom = padBot + 'px'; |
| 71 |
|
| 72 |
typoCssVarsForEl(app, opts.titleTypo, '--bkbg-inv-tt-'); |
| 73 |
|
| 74 |
var card = document.createElement('div'); |
| 75 |
card.className = 'bkbg-inv-card'; |
| 76 |
card.style.cssText = 'background:' + cardBg + ';border-radius:' + cardRadius + 'px;padding:36px 32px;max-width:' + maxWidth + 'px;margin:0 auto;'; |
| 77 |
app.appendChild(card); |
| 78 |
|
| 79 |
// Header |
| 80 |
var topRow = document.createElement('div'); |
| 81 |
topRow.style.cssText = 'display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:24px;flex-wrap:wrap;gap:12px;'; |
| 82 |
card.appendChild(topRow); |
| 83 |
|
| 84 |
var titleWrap = document.createElement('div'); |
| 85 |
if (showTitle && opts.title) { |
| 86 |
var titleEl = document.createElement('div'); |
| 87 |
titleEl.className = 'bkbg-inv-title'; |
| 88 |
titleEl.style.cssText = 'color:' + titleColor + ';margin-bottom:4px;'; |
| 89 |
titleEl.textContent = opts.title; |
| 90 |
titleWrap.appendChild(titleEl); |
| 91 |
} |
| 92 |
if (showSub && opts.subtitle) { |
| 93 |
var subEl = document.createElement('div'); |
| 94 |
subEl.style.cssText = 'font-size:14px;color:' + subtitleColor + ';'; |
| 95 |
subEl.textContent = opts.subtitle; |
| 96 |
titleWrap.appendChild(subEl); |
| 97 |
} |
| 98 |
topRow.appendChild(titleWrap); |
| 99 |
|
| 100 |
if (showInvNo) { |
| 101 |
var invNoWrap = document.createElement('div'); |
| 102 |
invNoWrap.style.textAlign = 'right'; |
| 103 |
var invLbl = document.createElement('div'); |
| 104 |
invLbl.style.cssText = 'font-size:12px;font-weight:600;color:' + labelColor + ';margin-bottom:4px;'; |
| 105 |
invLbl.textContent = 'Invoice #'; |
| 106 |
var invInp = document.createElement('input'); |
| 107 |
invInp.className = 'bkbg-inv-input'; |
| 108 |
invInp.value = 'INV-001'; |
| 109 |
invInp.style.cssText = 'width:120px;text-align:right;border-radius:' + inputRadius + 'px;border:1.5px solid ' + borderColor + ';border-color:' + borderColor + ';font-size:14px;'; |
| 110 |
invNoWrap.appendChild(invLbl); invNoWrap.appendChild(invInp); |
| 111 |
topRow.appendChild(invNoWrap); |
| 112 |
} |
| 113 |
|
| 114 |
if (showFromTo) { |
| 115 |
var ftGrid = document.createElement('div'); |
| 116 |
ftGrid.className = 'bkbg-inv-from-to'; |
| 117 |
ftGrid.style.marginBottom = '24px'; |
| 118 |
[['From', opts.fromName || ''], ['To', opts.toName || '']].forEach(function(pair) { |
| 119 |
var box = document.createElement('div'); |
| 120 |
box.style.cssText = 'padding:14px 16px;background:' + headerBg + ';border-radius:' + inputRadius + 'px;'; |
| 121 |
var lbl = document.createElement('div'); |
| 122 |
lbl.style.cssText = 'font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.04em;color:' + headerColor + ';margin-bottom:6px;'; |
| 123 |
lbl.textContent = pair[0]; |
| 124 |
var val = document.createElement('div'); |
| 125 |
val.style.cssText = 'font-size:14px;font-weight:600;color:' + labelColor + ';'; |
| 126 |
val.textContent = pair[1]; |
| 127 |
box.appendChild(lbl); box.appendChild(val); |
| 128 |
ftGrid.appendChild(box); |
| 129 |
}); |
| 130 |
card.appendChild(ftGrid); |
| 131 |
} |
| 132 |
|
| 133 |
// Table |
| 134 |
var tableWrap = document.createElement('div'); |
| 135 |
tableWrap.className = 'bkbg-inv-table-wrap'; |
| 136 |
tableWrap.style.cssText = 'overflow-x:auto;border-radius:' + inputRadius + 'px;border:1.5px solid ' + borderColor + ';margin-bottom:14px;'; |
| 137 |
card.appendChild(tableWrap); |
| 138 |
|
| 139 |
var table = document.createElement('table'); |
| 140 |
table.className = 'bkbg-inv-table'; |
| 141 |
tableWrap.appendChild(table); |
| 142 |
|
| 143 |
var thead = document.createElement('thead'); |
| 144 |
table.appendChild(thead); |
| 145 |
var thRow = document.createElement('tr'); |
| 146 |
thead.appendChild(thRow); |
| 147 |
[['Description','44%','left'],['Qty','12%','center'],['Price','20%','right'],['Total','18%','right'],['','6%','center']].forEach(function(col) { |
| 148 |
var th = document.createElement('th'); |
| 149 |
th.style.cssText = 'width:' + col[1] + ';text-align:' + col[2] + ';background:' + headerBg + ';color:' + headerColor + ';padding:10px 12px;font-size:12px;font-weight:700;text-transform:uppercase;letter-spacing:.04em;'; |
| 150 |
th.textContent = col[0]; |
| 151 |
thRow.appendChild(th); |
| 152 |
}); |
| 153 |
|
| 154 |
var tbody = document.createElement('tbody'); |
| 155 |
table.appendChild(tbody); |
| 156 |
|
| 157 |
// Totals |
| 158 |
var totalsWrap = document.createElement('div'); |
| 159 |
totalsWrap.className = 'bkbg-inv-totals'; |
| 160 |
card.appendChild(totalsWrap); |
| 161 |
|
| 162 |
var subtotalRow = makeTotalRow('Subtotal', ''); |
| 163 |
var discRow = makeTotalRow('Discount', ''); |
| 164 |
var taxRow = makeTotalRow('Tax', ''); |
| 165 |
var grandRow = document.createElement('div'); |
| 166 |
grandRow.className = 'bkbg-inv-grand-total'; |
| 167 |
grandRow.style.cssText = 'display:flex;justify-content:space-between;padding:14px 16px;background:' + totalBg + ';border-radius:' + inputRadius + 'px;margin-top:8px;color:' + totalColor + ';'; |
| 168 |
var grandLbl = document.createElement('span'); grandLbl.style.cssText = 'font-size:15px;font-weight:700;'; grandLbl.textContent = 'Total'; |
| 169 |
var grandVal = document.createElement('span'); grandVal.style.cssText = 'font-size:20px;font-weight:800;'; |
| 170 |
grandRow.appendChild(grandLbl); grandRow.appendChild(grandVal); |
| 171 |
|
| 172 |
// Tax/Disc inputs |
| 173 |
var discContainer = document.createElement('div'); |
| 174 |
discContainer.style.cssText = 'display:flex;align-items:center;gap:8px;'; |
| 175 |
var discInp = makeNumInp(discRate); |
| 176 |
var discAmt = document.createElement('span'); discAmt.style.cssText = 'font-weight:600;color:#ef4444;font-size:14px;'; |
| 177 |
discContainer.appendChild(discInp); discContainer.appendChild(discAmt); |
| 178 |
discRow.querySelector('.bkbg-inv-row-val').appendChild(discContainer); |
| 179 |
|
| 180 |
var taxContainer = document.createElement('div'); |
| 181 |
taxContainer.style.cssText = 'display:flex;align-items:center;gap:8px;'; |
| 182 |
var taxInp = makeNumInp(taxRate); |
| 183 |
var taxAmt = document.createElement('span'); taxAmt.style.cssText = 'font-weight:600;font-size:14px;color:' + labelColor + ';'; |
| 184 |
taxContainer.appendChild(taxInp); taxContainer.appendChild(taxAmt); |
| 185 |
taxRow.querySelector('.bkbg-inv-row-val').appendChild(taxContainer); |
| 186 |
|
| 187 |
totalsWrap.appendChild(subtotalRow); |
| 188 |
totalsWrap.appendChild(discRow); |
| 189 |
totalsWrap.appendChild(taxRow); |
| 190 |
totalsWrap.appendChild(grandRow); |
| 191 |
|
| 192 |
function makeTotalRow(lbl, val) { |
| 193 |
var div = document.createElement('div'); |
| 194 |
div.style.cssText = 'display:flex;justify-content:space-between;align-items:center;padding:8px 0;border-bottom:1px solid ' + borderColor + ';font-size:14px;color:' + labelColor + ';'; |
| 195 |
var l = document.createElement('span'); l.textContent = lbl; |
| 196 |
var v = document.createElement('span'); v.className = 'bkbg-inv-row-val'; v.style.fontWeight = '600'; |
| 197 |
if (val) v.textContent = val; |
| 198 |
div.appendChild(l); div.appendChild(v); |
| 199 |
return div; |
| 200 |
} |
| 201 |
|
| 202 |
function makeNumInp(defaultVal) { |
| 203 |
var inp = document.createElement('input'); |
| 204 |
inp.className = 'bkbg-inv-input'; |
| 205 |
inp.type = 'number'; inp.min = '0'; inp.max = '100'; inp.value = defaultVal; |
| 206 |
inp.style.cssText = 'width:60px;border-radius:' + inputRadius + 'px;border:1.5px solid ' + borderColor + ';font-size:13px;'; |
| 207 |
return inp; |
| 208 |
} |
| 209 |
|
| 210 |
function recalc() { |
| 211 |
var sub = items.reduce(function(s,i){ return s + (parseFloat(i.qty)||0)*(parseFloat(i.price)||0); }, 0); |
| 212 |
var disc2 = parseFloat(discInp.value) || 0; |
| 213 |
var tax2 = parseFloat(taxInp.value) || 0; |
| 214 |
var damt = sub * disc2 / 100; |
| 215 |
var tamt = (sub - damt) * tax2 / 100; |
| 216 |
var tot = sub - damt + tamt; |
| 217 |
subtotalRow.querySelector('.bkbg-inv-row-val').textContent = fmt(sym, sub); |
| 218 |
discAmt.textContent = '-' + fmt(sym, damt); |
| 219 |
taxAmt.textContent = '+' + fmt(sym, tamt); |
| 220 |
grandVal.textContent = fmt(sym, tot); |
| 221 |
} |
| 222 |
discInp.addEventListener('input', recalc); |
| 223 |
taxInp.addEventListener('input', recalc); |
| 224 |
|
| 225 |
// Add line item button |
| 226 |
var addBtn = document.createElement('button'); |
| 227 |
addBtn.className = 'bkbg-inv-add-btn'; |
| 228 |
addBtn.style.cssText = 'color:' + accent + ';border-color:' + borderColor + ';border-radius:' + inputRadius + 'px;'; |
| 229 |
addBtn.textContent = '+ Add Line Item'; |
| 230 |
addBtn.addEventListener('click', function() { |
| 231 |
items.push({ id: uid(), desc: '', qty: 1, price: 0 }); |
| 232 |
renderRows(); |
| 233 |
recalc(); |
| 234 |
}); |
| 235 |
card.insertBefore(addBtn, totalsWrap); |
| 236 |
|
| 237 |
function makeInpCell(val, type, width) { |
| 238 |
var inp = document.createElement('input'); |
| 239 |
inp.className = 'bkbg-inv-input'; |
| 240 |
inp.type = type || 'text'; inp.value = val; |
| 241 |
inp.style.cssText = 'width:' + (width || '100%') + ';border-radius:' + inputRadius + 'px;border:1.5px solid ' + borderColor + ';'; |
| 242 |
return inp; |
| 243 |
} |
| 244 |
|
| 245 |
function renderRows() { |
| 246 |
tbody.innerHTML = ''; |
| 247 |
items.forEach(function(item, idx) { |
| 248 |
var tr = document.createElement('tr'); |
| 249 |
tr.style.background = idx % 2 === 0 ? rowOddBg : rowEvenBg; |
| 250 |
|
| 251 |
// Desc |
| 252 |
var tdDesc = document.createElement('td'); tdDesc.style.padding = '8px 10px'; |
| 253 |
var descInp = makeInpCell(item.desc, 'text', '100%'); |
| 254 |
descInp.addEventListener('input', function(){ item.desc = descInp.value; }); |
| 255 |
tdDesc.appendChild(descInp); tr.appendChild(tdDesc); |
| 256 |
|
| 257 |
// Qty |
| 258 |
var tdQty = document.createElement('td'); tdQty.style.cssText = 'padding:8px 10px;text-align:center;'; |
| 259 |
var qtyInp = makeInpCell(item.qty, 'number', '60px'); |
| 260 |
qtyInp.addEventListener('input', function(){ item.qty = qtyInp.value; updateLineTotal(tdTotal, item); recalc(); }); |
| 261 |
tdQty.appendChild(qtyInp); tr.appendChild(tdQty); |
| 262 |
|
| 263 |
// Price |
| 264 |
var tdPrice = document.createElement('td'); tdPrice.style.cssText = 'padding:8px 10px;text-align:right;'; |
| 265 |
var priceInp = makeInpCell(item.price, 'number', '80px'); |
| 266 |
priceInp.style.textAlign = 'right'; |
| 267 |
priceInp.addEventListener('input', function(){ item.price = priceInp.value; updateLineTotal(tdTotal, item); recalc(); }); |
| 268 |
tdPrice.appendChild(priceInp); tr.appendChild(tdPrice); |
| 269 |
|
| 270 |
// Line total |
| 271 |
var tdTotal = document.createElement('td'); tdTotal.style.cssText = 'padding:8px 10px;text-align:right;font-weight:600;color:' + labelColor + ';font-size:14px;'; |
| 272 |
updateLineTotal(tdTotal, item); |
| 273 |
tr.appendChild(tdTotal); |
| 274 |
|
| 275 |
// Delete |
| 276 |
var tdDel = document.createElement('td'); tdDel.style.cssText = 'padding:8px 6px;text-align:center;'; |
| 277 |
var delBtn = document.createElement('button'); |
| 278 |
delBtn.className = 'bkbg-inv-del-btn'; |
| 279 |
delBtn.textContent = '×'; |
| 280 |
delBtn.addEventListener('click', function() { |
| 281 |
items = items.filter(function(i){ return i.id !== item.id; }); |
| 282 |
renderRows(); recalc(); |
| 283 |
}); |
| 284 |
tdDel.appendChild(delBtn); tr.appendChild(tdDel); |
| 285 |
|
| 286 |
tbody.appendChild(tr); |
| 287 |
}); |
| 288 |
recalc(); |
| 289 |
} |
| 290 |
|
| 291 |
function updateLineTotal(td, item) { |
| 292 |
td.textContent = fmt(sym, (parseFloat(item.qty)||0) * (parseFloat(item.price)||0)); |
| 293 |
} |
| 294 |
|
| 295 |
renderRows(); |
| 296 |
recalc(); |
| 297 |
} |
| 298 |
|
| 299 |
document.querySelectorAll('.bkbg-inv-app').forEach(initInvoice); |
| 300 |
})(); |
| 301 |
|