From 66260fec1b6c2658ccb861a92f49e968501ded6a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 24 May 2026 15:50:07 +0000 Subject: [PATCH] Support multiple program allocations with percentage split per expense line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- app/index.html | 127 ++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 110 insertions(+), 17 deletions(-) diff --git a/app/index.html b/app/index.html index 7dd5f85..cadca36 100644 --- a/app/index.html +++ b/app/index.html @@ -150,7 +150,8 @@ function newLine() { return { id: uid(), date: '', description: '', currency: state.baseCurrency, 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 const acctSel = makeSelect(CFG.accounts || [], ln.account, v => { ln.account = v; }, 'Select account'); - const progOptions = (CFG.programs || []); - 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]); + const progGrp = el('div', {className:'fgrp grow'}, [el('label', null, 'Program'), buildProgramArea(ln)]); blk.appendChild(el('div', {className:'frow'}, [ el('div', {className:'fgrp grow'}, [el('label', null, 'Account'), acctSel]), @@ -468,6 +459,79 @@ function buildReceiptArea(ln) { 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 ========== function validate() { const errs = []; @@ -490,8 +554,23 @@ function validate() { 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.program) errs.push(`${lx}: Program is required.`); - if (ln.program === 'Other' && !ln.programOther.trim()) errs.push(`${lx}: Please specify the program.`); + const progs = ln.programs || []; + 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.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}); y -= lh; pg.drawText(ln.account || '–', {x:M.left, y, size:sz, font:fontBody, color:black}); - const progStr = ln.program === 'Other' ? `Other: ${ln.programOther}` : (ln.program || '–'); - pg.drawText(progStr, {x:M.left+W*0.5, y, size:sz, font:fontBody, color:black}); - y -= lh; + const progs = ln.programs || []; + if (progs.length <= 1) { + 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 if (ln.hasReceipt && ln.receipts.length > 0) {