mirror of
https://github.com/kbenestad/reimburse.git
synced 2026-06-18 16:04:31 +00:00
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:
parent
7d49759c75
commit
66260fec1b
1 changed files with 110 additions and 17 deletions
127
app/index.html
127
app/index.html
|
|
@ -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) {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue