From 1e01329d18e9316bff44b91dcebc8a7534e60b59 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 24 May 2026 16:10:26 +0000 Subject: [PATCH] Show calculated base-currency amount alongside each programme percentage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When multiple programmes are allocated on a line, each row now displays the pro-rated amount in the base currency next to the percentage (e.g. "10.00% – USD 12.34"). The figure updates live as the line amount, FX rate, or currency changes, as well as when the percentage is edited. The PDF receipt renders the same information as a right-aligned suffix on each programme row. https://claude.ai/code/session_01MbwfxnjLA9KdFTrfzB55HM --- app/index.html | 158 +++++++++++++++++++++++++++++-------------------- 1 file changed, 95 insertions(+), 63 deletions(-) diff --git a/app/index.html b/app/index.html index cadca36..7b651c3 100644 --- a/app/index.html +++ b/app/index.html @@ -339,6 +339,7 @@ function renderItem(item) { function renderLine(ln, item) { const blk = el('div', {className:'line-blk', id:`line-${ln.id}`}); + let progAreaEl; const currencies = CFG.currencies || []; const baseCur = state.baseCurrency; @@ -362,6 +363,7 @@ function renderLine(ln, item) { else if (ln.fxRate === '1.00000') { ln.fxRate = ''; fxIn.value = ''; } } recalc(); + if (progAreaEl) progAreaEl._refresh(); }); const fxIn = el('input', {type:'text', value: ln.fxRate, style:{width:'120px'}, placeholder:'0.00000'}); @@ -373,6 +375,7 @@ function renderLine(ln, item) { const rate = parseFloat(fxIn.value); if (ln.currency && ln.currency !== baseCur && rate > 0) state.fxRateMemory[ln.currency] = fxIn.value; recalc(); + if (progAreaEl) progAreaEl._refresh(); }); blk.appendChild(el('div', {className:'frow'}, [ @@ -393,7 +396,7 @@ function renderLine(ln, item) { }); const amtIn = el('input', {type:'text', value: ln.amount, style:{width:'120px', textAlign:'right'}, placeholder:'0.00'}); - amtIn.addEventListener('input', () => { ln.amount = amtIn.value; recalc(); }); + amtIn.addEventListener('input', () => { ln.amount = amtIn.value; recalc(); if (progAreaEl) progAreaEl._refresh(); }); blk.appendChild(el('div', {className:'frow'}, [ el('div', {className:'fgrp grow'}, [el('label', null, 'Description'), descIn]), @@ -403,7 +406,8 @@ function renderLine(ln, item) { // Row 3: Account, Program const acctSel = makeSelect(CFG.accounts || [], ln.account, v => { ln.account = v; }, 'Select account'); - const progGrp = el('div', {className:'fgrp grow'}, [el('label', null, 'Program'), buildProgramArea(ln)]); + progAreaEl = buildProgramArea(ln); + const progGrp = el('div', {className:'fgrp grow'}, [el('label', null, 'Program'), progAreaEl]); blk.appendChild(el('div', {className:'frow'}, [ el('div', {className:'fgrp grow'}, [el('label', null, 'Account'), acctSel]), @@ -460,76 +464,101 @@ function buildReceiptArea(ln) { } function buildProgramArea(ln) { - const container = el('div'); + const wrapper = el('div'); const progOptions = CFG.programs || []; - const isMulti = ln.programs.length > 1; - let totalSpan = null; - function updateTotal() { - if (!totalSpan) return; - const sum = ln.programs.reduce((s, pe) => s + (parseFloat(pe.percent) || 0), 0); - totalSpan.textContent = sum.toFixed(2) + '%'; - totalSpan.style.color = sum < 99.995 ? '#c6a800' : sum <= 100.005 ? '#2e7d32' : '#c62828'; - } + function rebuild() { + while (wrapper.firstChild) wrapper.removeChild(wrapper.firstChild); + const isMulti = ln.programs.length > 1; + const amtSpans = []; + let totalSpan = null; - ln.programs.forEach((pe, pi) => { - const progSel = makeSelect(progOptions, pe.program, v => { - pe.program = v; - otherIn.style.display = v === 'Other' ? '' : 'none'; - }, 'Select program'); - progSel.style.flex = '1'; + function getBaseAmt() { + const amt = parseFloat(ln.amount) || 0; + const rate = parseFloat(ln.fxRate) || 1; + return rate > 0 ? amt / rate : 0; + } - const otherIn = el('input', {type:'text', value: pe.programOther, placeholder:'Specify program', - style:{display: pe.program === 'Other' ? '' : 'none', marginTop:'4px', width:'100%'}}); - otherIn.addEventListener('input', () => { pe.programOther = otherIn.value; }); + function updateDerived() { + const base = getBaseAmt(); + amtSpans.forEach((span, i) => { + const pct = parseFloat((ln.programs[i] || {}).percent) || 0; + span.textContent = `${state.baseCurrency} ${fmtAmt(base * pct / 100)}`; + }); + if (totalSpan) { + const sum = ln.programs.reduce((s, pe) => s + (parseFloat(pe.percent) || 0), 0); + totalSpan.textContent = sum.toFixed(2) + '%'; + totalSpan.style.color = sum < 99.995 ? '#c6a800' : sum <= 100.005 ? '#2e7d32' : '#c62828'; + } + } - if (!isMulti) { - const addBtn = el('button', {type:'button', className:'btn btn-add'}, '+ Add program'); + ln.programs.forEach((pe, pi) => { + const progSel = makeSelect(progOptions, pe.program, v => { + pe.program = v; + otherIn.style.display = v === 'Other' ? '' : 'none'; + }, 'Select program'); + progSel.style.flex = '1'; + + const otherIn = el('input', {type:'text', value: pe.programOther, placeholder:'Specify program', + style:{display: pe.program === 'Other' ? '' : 'none', marginTop:'4px', width:'100%'}}); + otherIn.addEventListener('input', () => { pe.programOther = otherIn.value; }); + + if (!isMulti) { + const addBtn = el('button', {type:'button', className:'btn btn-add'}, '+ Add program'); + addBtn.addEventListener('click', () => { + ln.programs.push({program:'', percent:'', programOther:''}); + rebuild(); + }); + const row = el('div', {style:{display:'flex', gap:'8px', alignItems:'center'}}); + row.append(progSel, addBtn); + wrapper.append(row, otherIn); + } else { + const pctIn = el('input', {type:'number', min:'0', max:'100', step:'0.01', + value: pe.percent, style:{width:'70px', textAlign:'right'}}); + pctIn.addEventListener('input', () => { pe.percent = pctIn.value; updateDerived(); }); + + const amtSpan = el('span', {style:{fontSize:'13px', color:'var(--muted)', whiteSpace:'nowrap'}}); + amtSpans.push(amtSpan); + + const rmBtn = el('button', {type:'button', className:'btn btn-rm', style:{padding:'3px 7px'}}, '×'); + rmBtn.addEventListener('click', () => { + ln.programs.splice(pi, 1); + rebuild(); + }); + + const row = el('div', {style:{display:'flex', gap:'8px', alignItems:'center', marginBottom:'4px'}}); + row.append(progSel, + el('span', {style:{fontSize:'13px', color:'var(--muted)', whiteSpace:'nowrap'}}, 'Percent:'), + pctIn, + el('span', {style:{fontSize:'13px'}}, '%'), + el('span', {style:{fontSize:'13px', color:'var(--muted)'}}, '–'), + amtSpan, + rmBtn); + wrapper.append(row, otherIn); + } + }); + + if (isMulti) { + totalSpan = el('span', {style:{fontWeight:'600', fontSize:'13px'}}); + const totalRow = el('div', + {style:{display:'flex', justifyContent:'flex-end', alignItems:'center', gap:'6px', marginTop:'4px', marginBottom:'4px'}}, + [el('span', {style:{fontSize:'13px', color:'var(--muted)'}}, 'Total percent:'), totalSpan]); + wrapper.appendChild(totalRow); + + const addBtn = el('button', {type:'button', className:'btn btn-add', style:{marginTop:'2px'}}, '+ Add program'); addBtn.addEventListener('click', () => { ln.programs.push({program:'', percent:'', programOther:''}); - container.replaceWith(buildProgramArea(ln)); + rebuild(); }); - const row = el('div', {style:{display:'flex', gap:'8px', alignItems:'center'}}); - row.append(progSel, addBtn); - container.append(row, otherIn); - } else { - const pctIn = el('input', {type:'number', min:'0', max:'100', step:'0.01', - value: pe.percent, style:{width:'70px', textAlign:'right'}}); - pctIn.addEventListener('input', () => { pe.percent = pctIn.value; updateTotal(); }); - - const rmBtn = el('button', {type:'button', className:'btn btn-rm', style:{padding:'3px 7px'}}, '×'); - rmBtn.addEventListener('click', () => { - ln.programs.splice(pi, 1); - container.replaceWith(buildProgramArea(ln)); - }); - - const row = el('div', {style:{display:'flex', gap:'8px', alignItems:'center', marginBottom:'4px'}}); - row.append(progSel, - el('span', {style:{fontSize:'13px', color:'var(--muted)', whiteSpace:'nowrap'}}, 'Percent:'), - pctIn, - el('span', {style:{fontSize:'13px'}}, '%'), - rmBtn); - container.append(row, otherIn); + wrapper.appendChild(addBtn); } - }); - if (isMulti) { - totalSpan = el('span', {style:{fontWeight:'600', fontSize:'13px'}}); - const totalRow = el('div', - {style:{display:'flex', justifyContent:'flex-end', alignItems:'center', gap:'6px', marginTop:'4px', marginBottom:'4px'}}, - [el('span', {style:{fontSize:'13px', color:'var(--muted)'}}, 'Total percent:'), totalSpan]); - container.appendChild(totalRow); - updateTotal(); - - const addBtn = el('button', {type:'button', className:'btn btn-add', style:{marginTop:'2px'}}, '+ Add program'); - addBtn.addEventListener('click', () => { - ln.programs.push({program:'', percent:'', programOther:''}); - container.replaceWith(buildProgramArea(ln)); - }); - container.appendChild(addBtn); + updateDerived(); } - return container; + wrapper._refresh = rebuild; + rebuild(); + return wrapper; } // ========== VALIDATION ========== @@ -748,13 +777,16 @@ async function generatePDF() { pg.drawText(truncate(progStr, fontBody, sz, W * 0.5 - 8), {x:M.left+W*0.5, y, size:sz, font:fontBody, color:black}); y -= lh; } else { + const lineBaseAmt = (() => { const a = parseFloat(ln.amount)||0, r = parseFloat(ln.fxRate)||1; return r>0?a/r:0; })(); progs.forEach((pe, pi) => { if (pi > 0) { needSpace(lh); } const progStr = pe.program === 'Other' ? `Other: ${pe.programOther}` : (pe.program || '–'); - const pctStr = ` (${parseFloat(pe.percent).toFixed(2)}%)`; + const pct = parseFloat(pe.percent) || 0; + const progAmt = lineBaseAmt * pct / 100; + const suffix = ` ${pct.toFixed(2)}% – ${baseCur} ${fmtAmt(progAmt)}`; pg.drawText(truncate(progStr, fontBody, sz, W * 0.45 - 8), {x:M.left+W*0.5, y, size:sz, font:fontBody, color:black}); - const pctW = fontBody.widthOfTextAtSize(pctStr, szSm); - pg.drawText(pctStr, {x:M.left+W-pctW, y, size:szSm, font:fontBody, color:gray}); + const sfxW = fontBody.widthOfTextAtSize(suffix, szSm); + pg.drawText(suffix, {x:M.left+W-sfxW, y, size:szSm, font:fontBody, color:gray}); y -= lh; }); }