diff --git a/index.html b/index.html index 089887f..878726a 100644 --- a/index.html +++ b/index.html @@ -36,10 +36,11 @@ textarea { resize: vertical; min-height: 48px; width: 100%; } /* Item block */ .item-blk { border: 1px solid var(--border); border-radius: 6px; padding: 20px; margin-bottom: 16px; background: #fafbfc; position: relative; } -.item-hdr { display: flex; justify-content: space-between; align-items: center; gap: 12px; margin-bottom: 4px; } +.item-hdr { display: flex; justify-content: space-between; align-items: center; gap: 12px; margin-bottom: 2px; } .item-hdr label { font-size: 12px; font-weight: 700; text-transform: uppercase; color: var(--accent); white-space: nowrap; } +.item-subtotal-row { display: flex; justify-content: flex-end; margin-bottom: 4px; } .item-subtotal { font-weight: 600; font-size: 14px; white-space: nowrap; color: var(--accent); } -.item-name { flex: 1; } +.item-name { width: 100%; box-sizing: border-box; } /* Line block */ .line-blk { padding: 12px 0; } @@ -122,12 +123,11 @@ function fmtAmt(n) { } function defaultPeriod() { const d = new Date(); - const lastDay = new Date(d.getFullYear(), d.getMonth()+1, 0).getDate() === d.getDate(); - const y = lastDay ? d.getFullYear() : (d.getMonth() === 0 ? d.getFullYear()-1 : d.getFullYear()); - const m = lastDay ? d.getMonth() : (d.getMonth() === 0 ? 11 : d.getMonth()-1); - const from = new Date(y, m, 1); - const to = new Date(y, m+1, 0); - return { from: from.toISOString().slice(0,10), to: to.toISOString().slice(0,10) }; + const isLastDay = d.getDate() === new Date(d.getFullYear(), d.getMonth() + 1, 0).getDate(); + const y = isLastDay ? d.getFullYear() : (d.getMonth() === 0 ? d.getFullYear() - 1 : d.getFullYear()); + const m = isLastDay ? d.getMonth() : (d.getMonth() === 0 ? 11 : d.getMonth() - 1); + const fmt = dt => `${dt.getFullYear()}-${String(dt.getMonth()+1).padStart(2,'0')}-${String(dt.getDate()).padStart(2,'0')}`; + return { from: fmt(new Date(y, m, 1)), to: fmt(new Date(y, m + 1, 0)) }; } // ========== CONFIG ========== @@ -140,12 +140,12 @@ async function loadConfig() { } // ========== STATE ========== -const state = { staff: '', periodFrom: '', periodTo: '', items: [], _grandTotal: 0 }; +const state = { staff: '', periodFrom: '', periodTo: '', baseCurrency: CFG['currency-base'], fxRateMemory: {}, items: [], _grandTotal: 0 }; function newItem() { return { id: uid(), name: '', lines: [newLine()], _subtotal: 0 }; } function newLine() { return { - id: uid(), date: '', description: '', currency: CFG['currency-base'], + id: uid(), date: '', description: '', currency: state.baseCurrency, fxRate: '1.00000', vendor: '', hasReceipt: true, receipts: [], noReceiptExplanation: '', amount: '', account: '', program: '', programOther: '' }; @@ -164,11 +164,11 @@ function recalc() { item._subtotal = sub; grand += sub; const se = $(`#sub-${item.id}`); - if (se) se.textContent = `${CFG['currency-base']} ${fmtAmt(sub)}`; + if (se) se.textContent = `${state.baseCurrency} ${fmtAmt(sub)}`; }); state._grandTotal = grand; const ge = $('#grand-total'); - if (ge) ge.textContent = `${CFG['currency-base']} ${fmtAmt(grand)}`; + if (ge) ge.textContent = `${state.baseCurrency} ${fmtAmt(grand)}`; } // ========== CURRENCY DROPDOWN ========== @@ -246,13 +246,21 @@ function render() { const toInput = el('input', {type:'date', value: state.periodTo}); toInput.addEventListener('change', () => { state.periodTo = toInput.value; }); - const baseBadge = el('span', {style:{fontWeight:'600', fontSize:'14px', padding:'7px 0'}}, CFG['currency-base']); + const baseCurDD = makeCDD(CFG.currencies || [], state.baseCurrency, code => { + state.baseCurrency = code; + const box = $('#items-box'); + if (box) { + box.innerHTML = ''; + state.items.forEach(item => box.appendChild(renderItem(item))); + } + recalc(); + }); wrap.appendChild(el('div', {className:'frow'}, [ el('div', {className:'fgrp grow'}, [el('label', null, 'Staff'), staffInput]), el('div', {className:'fgrp'}, [el('label', null, 'Period from'), fromInput]), el('div', {className:'fgrp'}, [el('label', null, 'to'), toInput]), - el('div', {className:'fgrp'}, [el('label', null, 'Base currency'), baseBadge]), + el('div', {className:'fgrp'}, [el('label', null, 'Base currency'), baseCurDD]), ])); wrap.appendChild(el('hr', {className:'divider'})); @@ -274,7 +282,7 @@ function render() { addItemBtn, el('div', {className:'grand-total'}, [ 'Total reimbursement claim: ', - el('span', {id:'grand-total'}, `${CFG['currency-base']} ${fmtAmt(0)}`) + el('span', {id:'grand-total'}, `${state.baseCurrency} ${fmtAmt(0)}`) ]) ])); @@ -300,12 +308,14 @@ function renderItem(item) { blk.appendChild(el('div', {className:'item-hdr'}, [ el('label', null, 'Item / Project / Travel'), - el('span', {className:'item-subtotal'}, [ - 'Subtotal: ', el('span', {id:`sub-${item.id}`}, `${CFG['currency-base']} ${fmtAmt(0)}`) - ]), rmBtn ])); blk.appendChild(nameIn); + blk.appendChild(el('div', {className:'item-subtotal-row'}, [ + el('span', {className:'item-subtotal'}, [ + 'Subtotal: ', el('span', {id:`sub-${item.id}`}, `${state.baseCurrency} ${fmtAmt(0)}`) + ]) + ])); // Lines container const linesBox = el('div', {id:`lines-${item.id}`}); @@ -327,39 +337,50 @@ function renderLine(ln, item) { const blk = el('div', {className:'line-blk', id:`line-${ln.id}`}); const currencies = CFG.currencies || []; - const baseCur = CFG['currency-base']; + const baseCur = state.baseCurrency; - // Row 1: Date, Description, Currency, FX Rate + // Row 1: Date, Vendor, Currency, FX Rate const dateIn = el('input', {type:'date', value: ln.date, style:{width:'140px'}}); dateIn.addEventListener('change', () => { ln.date = dateIn.value; }); - const descIn = el('input', {type:'text', value: ln.description, placeholder:'Description'}); - descIn.addEventListener('input', () => { ln.description = descIn.value; }); + const vendIn = el('input', {type:'text', value: ln.vendor, placeholder:'Vendor name'}); + vendIn.addEventListener('input', () => { ln.vendor = vendIn.value; }); const curDD = makeCDD(currencies, ln.currency, code => { ln.currency = code; fxIn.dataset.tip = `Units of ${code} per 1 ${baseCur}`; - if (code === baseCur) { ln.fxRate = '1.00000'; fxIn.value = '1.00000'; fxIn.readOnly = true; } - else { fxIn.readOnly = false; if (ln.fxRate === '1.00000') { ln.fxRate = ''; fxIn.value = ''; } } + if (code === baseCur) { + ln.fxRate = '1.00000'; fxIn.value = '1.00000'; fxIn.readOnly = true; + } else { + fxIn.readOnly = false; + const mem = state.fxRateMemory[code]; + if (mem) { ln.fxRate = mem; fxIn.value = mem; } + else if (ln.fxRate === '1.00000') { ln.fxRate = ''; fxIn.value = ''; } + } recalc(); }); - const fxIn = el('input', {type:'text', value: ln.fxRate, style:{width:'100px'}, placeholder:'0.00000'}); + const fxIn = el('input', {type:'text', value: ln.fxRate, style:{width:'100%'}, placeholder:'0.00000'}); fxIn.readOnly = ln.currency === baseCur; fxIn.className = 'tip'; fxIn.dataset.tip = `Units of ${ln.currency || '?'} per 1 ${baseCur}`; - fxIn.addEventListener('input', () => { ln.fxRate = fxIn.value; recalc(); }); + fxIn.addEventListener('input', () => { + ln.fxRate = fxIn.value; + const rate = parseFloat(fxIn.value); + if (ln.currency && ln.currency !== baseCur && rate > 0) state.fxRateMemory[ln.currency] = fxIn.value; + recalc(); + }); blk.appendChild(el('div', {className:'frow'}, [ el('div', {className:'fgrp'}, [el('label', null, 'Date'), dateIn]), - el('div', {className:'fgrp grow'}, [el('label', null, 'Description'), descIn]), + el('div', {className:'fgrp grow'}, [el('label', null, 'Vendor'), vendIn]), el('div', {className:'fgrp'}, [el('label', null, 'Currency'), curDD]), - el('div', {className:'fgrp'}, [el('label', null, 'FX rate'), fxIn]), + el('div', {className:'fgrp grow'}, [el('label', null, 'FX rate'), fxIn]), ])); - // Row 2: Vendor, Receipt, Amount - const vendIn = el('input', {type:'text', value: ln.vendor, placeholder:'Vendor name'}); - vendIn.addEventListener('input', () => { ln.vendor = vendIn.value; }); + // Row 2: Description, Receipt, Amount + const descIn = el('input', {type:'text', value: ln.description, placeholder:'Description'}); + descIn.addEventListener('input', () => { ln.description = descIn.value; }); const receiptSel = makeSelect(['Yes','No'], ln.hasReceipt ? 'Yes' : 'No', v => { ln.hasReceipt = v === 'Yes'; @@ -371,7 +392,7 @@ function renderLine(ln, item) { amtIn.addEventListener('input', () => { ln.amount = amtIn.value; recalc(); }); blk.appendChild(el('div', {className:'frow'}, [ - el('div', {className:'fgrp grow'}, [el('label', null, 'Vendor'), vendIn]), + el('div', {className:'fgrp grow'}, [el('label', null, 'Description'), descIn]), el('div', {className:'fgrp'}, [el('label', null, 'Receipt'), receiptSel]), el('div', {className:'fgrp'}, [el('label', null, 'Amount'), amtIn]), ])); @@ -461,7 +482,7 @@ function validate() { if (!ln.vendor.trim()) errs.push(`${lx}: Vendor is required.`); const amt = parseFloat(ln.amount); if (isNaN(amt) || amt <= 0) errs.push(`${lx}: Amount must be a positive number.`); - if (ln.currency !== CFG['currency-base']) { + if (ln.currency !== state.baseCurrency) { const rate = parseFloat(ln.fxRate); if (isNaN(rate) || rate <= 0) errs.push(`${lx}: FX rate must be a positive number.`); } @@ -499,7 +520,7 @@ async function generatePDF() { const black = rgb(0.13, 0.13, 0.13); const gray = rgb(0.45, 0.45, 0.45); const lineCol = rgb(0.75, 0.75, 0.75); - const baseCur = CFG['currency-base']; + const baseCur = state.baseCurrency; // Logo let logoImage = null; @@ -594,25 +615,25 @@ async function generatePDF() { // Row 1 labels pg.drawText('Date', {x:M.left+c1, y, size:szSm-1, font:fontBold, color:gray}); - pg.drawText('Description', {x:M.left+c2, y, size:szSm-1, font:fontBold, color:gray}); + pg.drawText('Vendor', {x:M.left+c2, y, size:szSm-1, font:fontBold, color:gray}); pg.drawText('Currency', {x:M.left+c3, y, size:szSm-1, font:fontBold, color:gray}); pg.drawText('FX rate', {x:M.left+c4, y, size:szSm-1, font:fontBold, color:gray}); y -= lh; // Row 1 values pg.drawText(ln.date || '–', {x:M.left+c1, y, size:sz, font:fontBody, color:black}); - pg.drawText(truncate(ln.description, fontBody, sz, (c3-c2)-8), {x:M.left+c2, y, size:sz, font:fontBody, color:black}); + pg.drawText(truncate(ln.vendor, fontBody, sz, (c3-c2)-8), {x:M.left+c2, y, size:sz, font:fontBody, color:black}); pg.drawText(ln.currency, {x:M.left+c3, y, size:sz, font:fontBody, color:black}); const fxStr = ln.currency === baseCur ? '–' : parseFloat(ln.fxRate).toFixed(5); pg.drawText(fxStr, {x:M.left+c4, y, size:sz, font:fontMono, color:black}); y -= lh + 2; // Row 2 labels - pg.drawText('Vendor', {x:M.left+r2v, y, size:szSm-1, font:fontBold, color:gray}); + pg.drawText('Description', {x:M.left+r2v, y, size:szSm-1, font:fontBold, color:gray}); pg.drawText('Receipt', {x:M.left+r2r, y, size:szSm-1, font:fontBold, color:gray}); pg.drawText('Amount', {x:M.left+r2a, y, size:szSm-1, font:fontBold, color:gray}); y -= lh; // Row 2 values - pg.drawText(truncate(ln.vendor, fontBody, sz, (r2r-r2v)-8), {x:M.left+r2v, y, size:sz, font:fontBody, color:black}); + pg.drawText(truncate(ln.description, fontBody, sz, (r2r-r2v)-8), {x:M.left+r2v, y, size:sz, font:fontBody, color:black}); pg.drawText(ln.hasReceipt ? 'Yes' : 'No', {x:M.left+r2r, y, size:sz, font:fontBody, color:black}); const amtStr = `${ln.currency} ${fmtAmt(ln.amount)}`; const amtW = fontMono.widthOfTextAtSize(amtStr, sz);