mirror of
https://github.com/kbenestad/reimburse.git
synced 2026-06-18 16:04:31 +00:00
Show calculated base-currency amount alongside each programme percentage
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
This commit is contained in:
parent
66260fec1b
commit
1e01329d18
1 changed files with 95 additions and 63 deletions
|
|
@ -339,6 +339,7 @@ function renderItem(item) {
|
||||||
|
|
||||||
function renderLine(ln, item) {
|
function renderLine(ln, item) {
|
||||||
const blk = el('div', {className:'line-blk', id:`line-${ln.id}`});
|
const blk = el('div', {className:'line-blk', id:`line-${ln.id}`});
|
||||||
|
let progAreaEl;
|
||||||
|
|
||||||
const currencies = CFG.currencies || [];
|
const currencies = CFG.currencies || [];
|
||||||
const baseCur = state.baseCurrency;
|
const baseCur = state.baseCurrency;
|
||||||
|
|
@ -362,6 +363,7 @@ function renderLine(ln, item) {
|
||||||
else if (ln.fxRate === '1.00000') { ln.fxRate = ''; fxIn.value = ''; }
|
else if (ln.fxRate === '1.00000') { ln.fxRate = ''; fxIn.value = ''; }
|
||||||
}
|
}
|
||||||
recalc();
|
recalc();
|
||||||
|
if (progAreaEl) progAreaEl._refresh();
|
||||||
});
|
});
|
||||||
|
|
||||||
const fxIn = el('input', {type:'text', value: ln.fxRate, style:{width:'120px'}, placeholder:'0.00000'});
|
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);
|
const rate = parseFloat(fxIn.value);
|
||||||
if (ln.currency && ln.currency !== baseCur && rate > 0) state.fxRateMemory[ln.currency] = fxIn.value;
|
if (ln.currency && ln.currency !== baseCur && rate > 0) state.fxRateMemory[ln.currency] = fxIn.value;
|
||||||
recalc();
|
recalc();
|
||||||
|
if (progAreaEl) progAreaEl._refresh();
|
||||||
});
|
});
|
||||||
|
|
||||||
blk.appendChild(el('div', {className:'frow'}, [
|
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'});
|
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'}, [
|
blk.appendChild(el('div', {className:'frow'}, [
|
||||||
el('div', {className:'fgrp grow'}, [el('label', null, 'Description'), descIn]),
|
el('div', {className:'fgrp grow'}, [el('label', null, 'Description'), descIn]),
|
||||||
|
|
@ -403,7 +406,8 @@ function renderLine(ln, item) {
|
||||||
|
|
||||||
// Row 3: Account, Program
|
// Row 3: Account, Program
|
||||||
const acctSel = makeSelect(CFG.accounts || [], ln.account, v => { ln.account = v; }, 'Select account');
|
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'}, [
|
blk.appendChild(el('div', {className:'frow'}, [
|
||||||
el('div', {className:'fgrp grow'}, [el('label', null, 'Account'), acctSel]),
|
el('div', {className:'fgrp grow'}, [el('label', null, 'Account'), acctSel]),
|
||||||
|
|
@ -460,17 +464,33 @@ function buildReceiptArea(ln) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildProgramArea(ln) {
|
function buildProgramArea(ln) {
|
||||||
const container = el('div');
|
const wrapper = el('div');
|
||||||
const progOptions = CFG.programs || [];
|
const progOptions = CFG.programs || [];
|
||||||
|
|
||||||
|
function rebuild() {
|
||||||
|
while (wrapper.firstChild) wrapper.removeChild(wrapper.firstChild);
|
||||||
const isMulti = ln.programs.length > 1;
|
const isMulti = ln.programs.length > 1;
|
||||||
|
const amtSpans = [];
|
||||||
let totalSpan = null;
|
let totalSpan = null;
|
||||||
|
|
||||||
function updateTotal() {
|
function getBaseAmt() {
|
||||||
if (!totalSpan) return;
|
const amt = parseFloat(ln.amount) || 0;
|
||||||
|
const rate = parseFloat(ln.fxRate) || 1;
|
||||||
|
return rate > 0 ? amt / rate : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
const sum = ln.programs.reduce((s, pe) => s + (parseFloat(pe.percent) || 0), 0);
|
||||||
totalSpan.textContent = sum.toFixed(2) + '%';
|
totalSpan.textContent = sum.toFixed(2) + '%';
|
||||||
totalSpan.style.color = sum < 99.995 ? '#c6a800' : sum <= 100.005 ? '#2e7d32' : '#c62828';
|
totalSpan.style.color = sum < 99.995 ? '#c6a800' : sum <= 100.005 ? '#2e7d32' : '#c62828';
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
ln.programs.forEach((pe, pi) => {
|
ln.programs.forEach((pe, pi) => {
|
||||||
const progSel = makeSelect(progOptions, pe.program, v => {
|
const progSel = makeSelect(progOptions, pe.program, v => {
|
||||||
|
|
@ -487,20 +507,23 @@ function buildProgramArea(ln) {
|
||||||
const addBtn = el('button', {type:'button', className:'btn btn-add'}, '+ Add program');
|
const addBtn = el('button', {type:'button', className:'btn btn-add'}, '+ Add program');
|
||||||
addBtn.addEventListener('click', () => {
|
addBtn.addEventListener('click', () => {
|
||||||
ln.programs.push({program:'', percent:'', programOther:''});
|
ln.programs.push({program:'', percent:'', programOther:''});
|
||||||
container.replaceWith(buildProgramArea(ln));
|
rebuild();
|
||||||
});
|
});
|
||||||
const row = el('div', {style:{display:'flex', gap:'8px', alignItems:'center'}});
|
const row = el('div', {style:{display:'flex', gap:'8px', alignItems:'center'}});
|
||||||
row.append(progSel, addBtn);
|
row.append(progSel, addBtn);
|
||||||
container.append(row, otherIn);
|
wrapper.append(row, otherIn);
|
||||||
} else {
|
} else {
|
||||||
const pctIn = el('input', {type:'number', min:'0', max:'100', step:'0.01',
|
const pctIn = el('input', {type:'number', min:'0', max:'100', step:'0.01',
|
||||||
value: pe.percent, style:{width:'70px', textAlign:'right'}});
|
value: pe.percent, style:{width:'70px', textAlign:'right'}});
|
||||||
pctIn.addEventListener('input', () => { pe.percent = pctIn.value; updateTotal(); });
|
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'}}, '×');
|
const rmBtn = el('button', {type:'button', className:'btn btn-rm', style:{padding:'3px 7px'}}, '×');
|
||||||
rmBtn.addEventListener('click', () => {
|
rmBtn.addEventListener('click', () => {
|
||||||
ln.programs.splice(pi, 1);
|
ln.programs.splice(pi, 1);
|
||||||
container.replaceWith(buildProgramArea(ln));
|
rebuild();
|
||||||
});
|
});
|
||||||
|
|
||||||
const row = el('div', {style:{display:'flex', gap:'8px', alignItems:'center', marginBottom:'4px'}});
|
const row = el('div', {style:{display:'flex', gap:'8px', alignItems:'center', marginBottom:'4px'}});
|
||||||
|
|
@ -508,8 +531,10 @@ function buildProgramArea(ln) {
|
||||||
el('span', {style:{fontSize:'13px', color:'var(--muted)', whiteSpace:'nowrap'}}, 'Percent:'),
|
el('span', {style:{fontSize:'13px', color:'var(--muted)', whiteSpace:'nowrap'}}, 'Percent:'),
|
||||||
pctIn,
|
pctIn,
|
||||||
el('span', {style:{fontSize:'13px'}}, '%'),
|
el('span', {style:{fontSize:'13px'}}, '%'),
|
||||||
|
el('span', {style:{fontSize:'13px', color:'var(--muted)'}}, '–'),
|
||||||
|
amtSpan,
|
||||||
rmBtn);
|
rmBtn);
|
||||||
container.append(row, otherIn);
|
wrapper.append(row, otherIn);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -518,18 +543,22 @@ function buildProgramArea(ln) {
|
||||||
const totalRow = el('div',
|
const totalRow = el('div',
|
||||||
{style:{display:'flex', justifyContent:'flex-end', alignItems:'center', gap:'6px', marginTop:'4px', marginBottom:'4px'}},
|
{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]);
|
[el('span', {style:{fontSize:'13px', color:'var(--muted)'}}, 'Total percent:'), totalSpan]);
|
||||||
container.appendChild(totalRow);
|
wrapper.appendChild(totalRow);
|
||||||
updateTotal();
|
|
||||||
|
|
||||||
const addBtn = el('button', {type:'button', className:'btn btn-add', style:{marginTop:'2px'}}, '+ Add program');
|
const addBtn = el('button', {type:'button', className:'btn btn-add', style:{marginTop:'2px'}}, '+ Add program');
|
||||||
addBtn.addEventListener('click', () => {
|
addBtn.addEventListener('click', () => {
|
||||||
ln.programs.push({program:'', percent:'', programOther:''});
|
ln.programs.push({program:'', percent:'', programOther:''});
|
||||||
container.replaceWith(buildProgramArea(ln));
|
rebuild();
|
||||||
});
|
});
|
||||||
container.appendChild(addBtn);
|
wrapper.appendChild(addBtn);
|
||||||
}
|
}
|
||||||
|
|
||||||
return container;
|
updateDerived();
|
||||||
|
}
|
||||||
|
|
||||||
|
wrapper._refresh = rebuild;
|
||||||
|
rebuild();
|
||||||
|
return wrapper;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========== VALIDATION ==========
|
// ========== 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});
|
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;
|
y -= lh;
|
||||||
} else {
|
} else {
|
||||||
|
const lineBaseAmt = (() => { const a = parseFloat(ln.amount)||0, r = parseFloat(ln.fxRate)||1; return r>0?a/r:0; })();
|
||||||
progs.forEach((pe, pi) => {
|
progs.forEach((pe, pi) => {
|
||||||
if (pi > 0) { needSpace(lh); }
|
if (pi > 0) { needSpace(lh); }
|
||||||
const progStr = pe.program === 'Other' ? `Other: ${pe.programOther}` : (pe.program || '–');
|
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});
|
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);
|
const sfxW = fontBody.widthOfTextAtSize(suffix, szSm);
|
||||||
pg.drawText(pctStr, {x:M.left+W-pctW, y, size:szSm, font:fontBody, color:gray});
|
pg.drawText(suffix, {x:M.left+W-sfxW, y, size:szSm, font:fontBody, color:gray});
|
||||||
y -= lh;
|
y -= lh;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue