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) {