Support multiple program allocations with percentage split per expense line

Replaces single program field with a dynamic multi-program UI. A single
program shows the select inline with an "+ Add program" button and no
percentages. Adding a second program switches the layout to show a percent
input per row, a colour-coded total (yellow <100%, green =100%, red >100%),
and a × button to remove individual rows. Returning to one program reverts
to the simple layout. PDF renders programmes stacked with (XX.XX%) suffix
when multiple are present. Validation requires each selection, all percents
positive, and total = 100% when multiple programmes are allocated.

https://claude.ai/code/session_01MbwfxnjLA9KdFTrfzB55HM
This commit is contained in:
Claude 2026-05-24 15:50:07 +00:00
parent 7d49759c75
commit 66260fec1b
No known key found for this signature in database

View file

@ -150,7 +150,8 @@ function newLine() {
return { return {
id: uid(), date: '', description: '', currency: state.baseCurrency, id: uid(), date: '', description: '', currency: state.baseCurrency,
fxRate: '1.00000', vendor: '', hasReceipt: true, receipts: [], fxRate: '1.00000', vendor: '', hasReceipt: true, receipts: [],
noReceiptExplanation: '', amount: '', account: '', program: '', programOther: '' noReceiptExplanation: '', amount: '', account: '',
programs: [{ program: '', percent: '', programOther: '' }]
}; };
} }
@ -402,17 +403,7 @@ 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 progOptions = (CFG.programs || []); const progGrp = el('div', {className:'fgrp grow'}, [el('label', null, 'Program'), buildProgramArea(ln)]);
const progSel = makeSelect(progOptions, ln.program, v => {
ln.program = v;
const otherBox = $(`#prog-other-${ln.id}`);
if (otherBox) otherBox.style.display = v === 'Other' ? 'block' : 'none';
}, 'Select program');
const progOtherIn = el('input', {type:'text', id:`prog-other-${ln.id}`, value: ln.programOther, placeholder:'Specify program', style:{display: ln.program === 'Other' ? 'block' : 'none', marginTop:'6px'}});
progOtherIn.addEventListener('input', () => { ln.programOther = progOtherIn.value; });
const progGrp = el('div', {className:'fgrp grow'}, [el('label', null, 'Program'), progSel, progOtherIn]);
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]),
@ -468,6 +459,79 @@ function buildReceiptArea(ln) {
return area; return area;
} }
function buildProgramArea(ln) {
const container = 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';
}
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:''});
container.replaceWith(buildProgramArea(ln));
});
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);
}
});
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);
}
return container;
}
// ========== VALIDATION ========== // ========== VALIDATION ==========
function validate() { function validate() {
const errs = []; const errs = [];
@ -490,8 +554,23 @@ function validate() {
if (isNaN(rate) || rate <= 0) errs.push(`${lx}: FX rate must be a positive number.`); if (isNaN(rate) || rate <= 0) errs.push(`${lx}: FX rate must be a positive number.`);
} }
if (!ln.account) errs.push(`${lx}: Account is required.`); if (!ln.account) errs.push(`${lx}: Account is required.`);
if (!ln.program) errs.push(`${lx}: Program is required.`); const progs = ln.programs || [];
if (ln.program === 'Other' && !ln.programOther.trim()) errs.push(`${lx}: Please specify the program.`); if (progs.length === 0 || !progs[0].program) {
errs.push(`${lx}: Program is required.`);
} else {
progs.forEach((pe, pi) => {
if (!pe.program) errs.push(`${lx}: Program ${pi+1} selection is required.`);
if (pe.program === 'Other' && !pe.programOther.trim()) errs.push(`${lx}: Please specify program ${pi+1}.`);
});
if (progs.length > 1) {
progs.forEach((pe, pi) => {
const pct = parseFloat(pe.percent);
if (isNaN(pct) || pct <= 0) errs.push(`${lx}: Percent for program ${pi+1} must be a positive number.`);
});
const total = progs.reduce((s, pe) => s + (parseFloat(pe.percent) || 0), 0);
if (Math.abs(total - 100) > 0.005) errs.push(`${lx}: Program percentages must total 100% (currently ${total.toFixed(2)}%).`);
}
}
if (ln.hasReceipt && ln.receipts.length === 0) errs.push(`${lx}: Upload at least one receipt, or select "No" for receipt.`); if (ln.hasReceipt && ln.receipts.length === 0) errs.push(`${lx}: Upload at least one receipt, or select "No" for receipt.`);
if (!ln.hasReceipt && !ln.noReceiptExplanation.trim()) errs.push(`${lx}: Explain why there is no receipt.`); if (!ln.hasReceipt && !ln.noReceiptExplanation.trim()) errs.push(`${lx}: Explain why there is no receipt.`);
}); });
@ -662,9 +741,23 @@ async function generatePDF() {
pg.drawText('Program', {x:M.left+W*0.5, y, size:szSm-1, font:fontBold, color:gray}); pg.drawText('Program', {x:M.left+W*0.5, y, size:szSm-1, font:fontBold, color:gray});
y -= lh; y -= lh;
pg.drawText(ln.account || '', {x:M.left, y, size:sz, font:fontBody, color:black}); pg.drawText(ln.account || '', {x:M.left, y, size:sz, font:fontBody, color:black});
const progStr = ln.program === 'Other' ? `Other: ${ln.programOther}` : (ln.program || ''); const progs = ln.programs || [];
pg.drawText(progStr, {x:M.left+W*0.5, y, size:sz, font:fontBody, color:black}); if (progs.length <= 1) {
y -= lh; const pe = progs[0] || {};
const progStr = pe.program === 'Other' ? `Other: ${pe.programOther}` : (pe.program || '');
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 {
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)}%)`;
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});
y -= lh;
});
}
// Receipt reference or explanation // Receipt reference or explanation
if (ln.hasReceipt && ln.receipts.length > 0) { if (ln.hasReceipt && ln.receipts.length > 0) {