Merge pull request #1 from kbenestad/claude/fix-form-layout-P8uHa

Make base currency dynamic and reorganize line item layout
This commit is contained in:
Kristian Benestad 2026-05-13 16:27:32 +07:00 committed by GitHub
commit edc9d915e0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -36,10 +36,11 @@ textarea { resize: vertical; min-height: 48px; width: 100%; }
/* Item block */ /* Item block */
.item-blk { border: 1px solid var(--border); border-radius: 6px; padding: 20px; margin-bottom: 16px; background: #fafbfc; position: relative; } .item-blk { border: 1px solid var(--border); border-radius: 6px; padding: 20px; margin-bottom: 16px; background: #fafbfc; position: relative; }
.item-hdr { display: flex; justify-content: space-between; align-items: center; gap: 12px; margin-bottom: 4px; } .item-hdr { display: flex; justify-content: space-between; align-items: center; gap: 12px; margin-bottom: 2px; }
.item-hdr label { font-size: 12px; font-weight: 700; text-transform: uppercase; color: var(--accent); white-space: nowrap; } .item-hdr label { font-size: 12px; font-weight: 700; text-transform: uppercase; color: var(--accent); white-space: nowrap; }
.item-subtotal-row { display: flex; justify-content: flex-end; margin-bottom: 4px; }
.item-subtotal { font-weight: 600; font-size: 14px; white-space: nowrap; color: var(--accent); } .item-subtotal { font-weight: 600; font-size: 14px; white-space: nowrap; color: var(--accent); }
.item-name { flex: 1; } .item-name { width: 100%; box-sizing: border-box; }
/* Line block */ /* Line block */
.line-blk { padding: 12px 0; } .line-blk { padding: 12px 0; }
@ -122,12 +123,11 @@ function fmtAmt(n) {
} }
function defaultPeriod() { function defaultPeriod() {
const d = new Date(); const d = new Date();
const lastDay = new Date(d.getFullYear(), d.getMonth()+1, 0).getDate() === d.getDate(); const isLastDay = d.getDate() === new Date(d.getFullYear(), d.getMonth() + 1, 0).getDate();
const y = lastDay ? d.getFullYear() : (d.getMonth() === 0 ? d.getFullYear()-1 : d.getFullYear()); const y = isLastDay ? d.getFullYear() : (d.getMonth() === 0 ? d.getFullYear() - 1 : d.getFullYear());
const m = lastDay ? d.getMonth() : (d.getMonth() === 0 ? 11 : d.getMonth()-1); const m = isLastDay ? d.getMonth() : (d.getMonth() === 0 ? 11 : d.getMonth() - 1);
const from = new Date(y, m, 1); const fmt = dt => `${dt.getFullYear()}-${String(dt.getMonth()+1).padStart(2,'0')}-${String(dt.getDate()).padStart(2,'0')}`;
const to = new Date(y, m+1, 0); return { from: fmt(new Date(y, m, 1)), to: fmt(new Date(y, m + 1, 0)) };
return { from: from.toISOString().slice(0,10), to: to.toISOString().slice(0,10) };
} }
// ========== CONFIG ========== // ========== CONFIG ==========
@ -140,12 +140,12 @@ async function loadConfig() {
} }
// ========== STATE ========== // ========== STATE ==========
const state = { staff: '', periodFrom: '', periodTo: '', items: [], _grandTotal: 0 }; const state = { staff: '', periodFrom: '', periodTo: '', baseCurrency: CFG['currency-base'], fxRateMemory: {}, items: [], _grandTotal: 0 };
function newItem() { return { id: uid(), name: '', lines: [newLine()], _subtotal: 0 }; } function newItem() { return { id: uid(), name: '', lines: [newLine()], _subtotal: 0 }; }
function newLine() { function newLine() {
return { return {
id: uid(), date: '', description: '', currency: CFG['currency-base'], 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: '', program: '', programOther: ''
}; };
@ -164,11 +164,11 @@ function recalc() {
item._subtotal = sub; item._subtotal = sub;
grand += sub; grand += sub;
const se = $(`#sub-${item.id}`); const se = $(`#sub-${item.id}`);
if (se) se.textContent = `${CFG['currency-base']} ${fmtAmt(sub)}`; if (se) se.textContent = `${state.baseCurrency} ${fmtAmt(sub)}`;
}); });
state._grandTotal = grand; state._grandTotal = grand;
const ge = $('#grand-total'); const ge = $('#grand-total');
if (ge) ge.textContent = `${CFG['currency-base']} ${fmtAmt(grand)}`; if (ge) ge.textContent = `${state.baseCurrency} ${fmtAmt(grand)}`;
} }
// ========== CURRENCY DROPDOWN ========== // ========== CURRENCY DROPDOWN ==========
@ -246,13 +246,21 @@ function render() {
const toInput = el('input', {type:'date', value: state.periodTo}); const toInput = el('input', {type:'date', value: state.periodTo});
toInput.addEventListener('change', () => { state.periodTo = toInput.value; }); toInput.addEventListener('change', () => { state.periodTo = toInput.value; });
const baseBadge = el('span', {style:{fontWeight:'600', fontSize:'14px', padding:'7px 0'}}, CFG['currency-base']); const baseCurDD = makeCDD(CFG.currencies || [], state.baseCurrency, code => {
state.baseCurrency = code;
const box = $('#items-box');
if (box) {
box.innerHTML = '';
state.items.forEach(item => box.appendChild(renderItem(item)));
}
recalc();
});
wrap.appendChild(el('div', {className:'frow'}, [ wrap.appendChild(el('div', {className:'frow'}, [
el('div', {className:'fgrp grow'}, [el('label', null, 'Staff'), staffInput]), el('div', {className:'fgrp grow'}, [el('label', null, 'Staff'), staffInput]),
el('div', {className:'fgrp'}, [el('label', null, 'Period from'), fromInput]), el('div', {className:'fgrp'}, [el('label', null, 'Period from'), fromInput]),
el('div', {className:'fgrp'}, [el('label', null, 'to'), toInput]), el('div', {className:'fgrp'}, [el('label', null, 'to'), toInput]),
el('div', {className:'fgrp'}, [el('label', null, 'Base currency'), baseBadge]), el('div', {className:'fgrp'}, [el('label', null, 'Base currency'), baseCurDD]),
])); ]));
wrap.appendChild(el('hr', {className:'divider'})); wrap.appendChild(el('hr', {className:'divider'}));
@ -274,7 +282,7 @@ function render() {
addItemBtn, addItemBtn,
el('div', {className:'grand-total'}, [ el('div', {className:'grand-total'}, [
'Total reimbursement claim: ', 'Total reimbursement claim: ',
el('span', {id:'grand-total'}, `${CFG['currency-base']} ${fmtAmt(0)}`) el('span', {id:'grand-total'}, `${state.baseCurrency} ${fmtAmt(0)}`)
]) ])
])); ]));
@ -300,12 +308,14 @@ function renderItem(item) {
blk.appendChild(el('div', {className:'item-hdr'}, [ blk.appendChild(el('div', {className:'item-hdr'}, [
el('label', null, 'Item / Project / Travel'), el('label', null, 'Item / Project / Travel'),
el('span', {className:'item-subtotal'}, [
'Subtotal: ', el('span', {id:`sub-${item.id}`}, `${CFG['currency-base']} ${fmtAmt(0)}`)
]),
rmBtn rmBtn
])); ]));
blk.appendChild(nameIn); blk.appendChild(nameIn);
blk.appendChild(el('div', {className:'item-subtotal-row'}, [
el('span', {className:'item-subtotal'}, [
'Subtotal: ', el('span', {id:`sub-${item.id}`}, `${state.baseCurrency} ${fmtAmt(0)}`)
])
]));
// Lines container // Lines container
const linesBox = el('div', {id:`lines-${item.id}`}); const linesBox = el('div', {id:`lines-${item.id}`});
@ -327,39 +337,50 @@ 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}`});
const currencies = CFG.currencies || []; const currencies = CFG.currencies || [];
const baseCur = CFG['currency-base']; const baseCur = state.baseCurrency;
// Row 1: Date, Description, Currency, FX Rate // Row 1: Date, Vendor, Currency, FX Rate
const dateIn = el('input', {type:'date', value: ln.date, style:{width:'140px'}}); const dateIn = el('input', {type:'date', value: ln.date, style:{width:'140px'}});
dateIn.addEventListener('change', () => { ln.date = dateIn.value; }); dateIn.addEventListener('change', () => { ln.date = dateIn.value; });
const descIn = el('input', {type:'text', value: ln.description, placeholder:'Description'}); const vendIn = el('input', {type:'text', value: ln.vendor, placeholder:'Vendor name'});
descIn.addEventListener('input', () => { ln.description = descIn.value; }); vendIn.addEventListener('input', () => { ln.vendor = vendIn.value; });
const curDD = makeCDD(currencies, ln.currency, code => { const curDD = makeCDD(currencies, ln.currency, code => {
ln.currency = code; ln.currency = code;
fxIn.dataset.tip = `Units of ${code} per 1 ${baseCur}`; fxIn.dataset.tip = `Units of ${code} per 1 ${baseCur}`;
if (code === baseCur) { ln.fxRate = '1.00000'; fxIn.value = '1.00000'; fxIn.readOnly = true; } if (code === baseCur) {
else { fxIn.readOnly = false; if (ln.fxRate === '1.00000') { ln.fxRate = ''; fxIn.value = ''; } } ln.fxRate = '1.00000'; fxIn.value = '1.00000'; fxIn.readOnly = true;
} else {
fxIn.readOnly = false;
const mem = state.fxRateMemory[code];
if (mem) { ln.fxRate = mem; fxIn.value = mem; }
else if (ln.fxRate === '1.00000') { ln.fxRate = ''; fxIn.value = ''; }
}
recalc(); recalc();
}); });
const fxIn = el('input', {type:'text', value: ln.fxRate, style:{width:'100px'}, placeholder:'0.00000'}); const fxIn = el('input', {type:'text', value: ln.fxRate, style:{width:'100%'}, placeholder:'0.00000'});
fxIn.readOnly = ln.currency === baseCur; fxIn.readOnly = ln.currency === baseCur;
fxIn.className = 'tip'; fxIn.className = 'tip';
fxIn.dataset.tip = `Units of ${ln.currency || '?'} per 1 ${baseCur}`; fxIn.dataset.tip = `Units of ${ln.currency || '?'} per 1 ${baseCur}`;
fxIn.addEventListener('input', () => { ln.fxRate = fxIn.value; recalc(); }); fxIn.addEventListener('input', () => {
ln.fxRate = fxIn.value;
const rate = parseFloat(fxIn.value);
if (ln.currency && ln.currency !== baseCur && rate > 0) state.fxRateMemory[ln.currency] = fxIn.value;
recalc();
});
blk.appendChild(el('div', {className:'frow'}, [ blk.appendChild(el('div', {className:'frow'}, [
el('div', {className:'fgrp'}, [el('label', null, 'Date'), dateIn]), el('div', {className:'fgrp'}, [el('label', null, 'Date'), dateIn]),
el('div', {className:'fgrp grow'}, [el('label', null, 'Description'), descIn]), el('div', {className:'fgrp grow'}, [el('label', null, 'Vendor'), vendIn]),
el('div', {className:'fgrp'}, [el('label', null, 'Currency'), curDD]), el('div', {className:'fgrp'}, [el('label', null, 'Currency'), curDD]),
el('div', {className:'fgrp'}, [el('label', null, 'FX rate'), fxIn]), el('div', {className:'fgrp grow'}, [el('label', null, 'FX rate'), fxIn]),
])); ]));
// Row 2: Vendor, Receipt, Amount // Row 2: Description, Receipt, Amount
const vendIn = el('input', {type:'text', value: ln.vendor, placeholder:'Vendor name'}); const descIn = el('input', {type:'text', value: ln.description, placeholder:'Description'});
vendIn.addEventListener('input', () => { ln.vendor = vendIn.value; }); descIn.addEventListener('input', () => { ln.description = descIn.value; });
const receiptSel = makeSelect(['Yes','No'], ln.hasReceipt ? 'Yes' : 'No', v => { const receiptSel = makeSelect(['Yes','No'], ln.hasReceipt ? 'Yes' : 'No', v => {
ln.hasReceipt = v === 'Yes'; ln.hasReceipt = v === 'Yes';
@ -371,7 +392,7 @@ function renderLine(ln, item) {
amtIn.addEventListener('input', () => { ln.amount = amtIn.value; recalc(); }); amtIn.addEventListener('input', () => { ln.amount = amtIn.value; recalc(); });
blk.appendChild(el('div', {className:'frow'}, [ blk.appendChild(el('div', {className:'frow'}, [
el('div', {className:'fgrp grow'}, [el('label', null, 'Vendor'), vendIn]), el('div', {className:'fgrp grow'}, [el('label', null, 'Description'), descIn]),
el('div', {className:'fgrp'}, [el('label', null, 'Receipt'), receiptSel]), el('div', {className:'fgrp'}, [el('label', null, 'Receipt'), receiptSel]),
el('div', {className:'fgrp'}, [el('label', null, 'Amount'), amtIn]), el('div', {className:'fgrp'}, [el('label', null, 'Amount'), amtIn]),
])); ]));
@ -461,7 +482,7 @@ function validate() {
if (!ln.vendor.trim()) errs.push(`${lx}: Vendor is required.`); if (!ln.vendor.trim()) errs.push(`${lx}: Vendor is required.`);
const amt = parseFloat(ln.amount); const amt = parseFloat(ln.amount);
if (isNaN(amt) || amt <= 0) errs.push(`${lx}: Amount must be a positive number.`); if (isNaN(amt) || amt <= 0) errs.push(`${lx}: Amount must be a positive number.`);
if (ln.currency !== CFG['currency-base']) { if (ln.currency !== state.baseCurrency) {
const rate = parseFloat(ln.fxRate); const rate = parseFloat(ln.fxRate);
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.`);
} }
@ -499,7 +520,7 @@ async function generatePDF() {
const black = rgb(0.13, 0.13, 0.13); const black = rgb(0.13, 0.13, 0.13);
const gray = rgb(0.45, 0.45, 0.45); const gray = rgb(0.45, 0.45, 0.45);
const lineCol = rgb(0.75, 0.75, 0.75); const lineCol = rgb(0.75, 0.75, 0.75);
const baseCur = CFG['currency-base']; const baseCur = state.baseCurrency;
// Logo // Logo
let logoImage = null; let logoImage = null;
@ -594,25 +615,25 @@ async function generatePDF() {
// Row 1 labels // Row 1 labels
pg.drawText('Date', {x:M.left+c1, y, size:szSm-1, font:fontBold, color:gray}); pg.drawText('Date', {x:M.left+c1, y, size:szSm-1, font:fontBold, color:gray});
pg.drawText('Description', {x:M.left+c2, y, size:szSm-1, font:fontBold, color:gray}); pg.drawText('Vendor', {x:M.left+c2, y, size:szSm-1, font:fontBold, color:gray});
pg.drawText('Currency', {x:M.left+c3, y, size:szSm-1, font:fontBold, color:gray}); pg.drawText('Currency', {x:M.left+c3, y, size:szSm-1, font:fontBold, color:gray});
pg.drawText('FX rate', {x:M.left+c4, y, size:szSm-1, font:fontBold, color:gray}); pg.drawText('FX rate', {x:M.left+c4, y, size:szSm-1, font:fontBold, color:gray});
y -= lh; y -= lh;
// Row 1 values // Row 1 values
pg.drawText(ln.date || '', {x:M.left+c1, y, size:sz, font:fontBody, color:black}); pg.drawText(ln.date || '', {x:M.left+c1, y, size:sz, font:fontBody, color:black});
pg.drawText(truncate(ln.description, fontBody, sz, (c3-c2)-8), {x:M.left+c2, y, size:sz, font:fontBody, color:black}); pg.drawText(truncate(ln.vendor, fontBody, sz, (c3-c2)-8), {x:M.left+c2, y, size:sz, font:fontBody, color:black});
pg.drawText(ln.currency, {x:M.left+c3, y, size:sz, font:fontBody, color:black}); pg.drawText(ln.currency, {x:M.left+c3, y, size:sz, font:fontBody, color:black});
const fxStr = ln.currency === baseCur ? '' : parseFloat(ln.fxRate).toFixed(5); const fxStr = ln.currency === baseCur ? '' : parseFloat(ln.fxRate).toFixed(5);
pg.drawText(fxStr, {x:M.left+c4, y, size:sz, font:fontMono, color:black}); pg.drawText(fxStr, {x:M.left+c4, y, size:sz, font:fontMono, color:black});
y -= lh + 2; y -= lh + 2;
// Row 2 labels // Row 2 labels
pg.drawText('Vendor', {x:M.left+r2v, y, size:szSm-1, font:fontBold, color:gray}); pg.drawText('Description', {x:M.left+r2v, y, size:szSm-1, font:fontBold, color:gray});
pg.drawText('Receipt', {x:M.left+r2r, y, size:szSm-1, font:fontBold, color:gray}); pg.drawText('Receipt', {x:M.left+r2r, y, size:szSm-1, font:fontBold, color:gray});
pg.drawText('Amount', {x:M.left+r2a, y, size:szSm-1, font:fontBold, color:gray}); pg.drawText('Amount', {x:M.left+r2a, y, size:szSm-1, font:fontBold, color:gray});
y -= lh; y -= lh;
// Row 2 values // Row 2 values
pg.drawText(truncate(ln.vendor, fontBody, sz, (r2r-r2v)-8), {x:M.left+r2v, y, size:sz, font:fontBody, color:black}); pg.drawText(truncate(ln.description, fontBody, sz, (r2r-r2v)-8), {x:M.left+r2v, y, size:sz, font:fontBody, color:black});
pg.drawText(ln.hasReceipt ? 'Yes' : 'No', {x:M.left+r2r, y, size:sz, font:fontBody, color:black}); pg.drawText(ln.hasReceipt ? 'Yes' : 'No', {x:M.left+r2r, y, size:sz, font:fontBody, color:black});
const amtStr = `${ln.currency} ${fmtAmt(ln.amount)}`; const amtStr = `${ln.currency} ${fmtAmt(ln.amount)}`;
const amtW = fontMono.widthOfTextAtSize(amtStr, sz); const amtW = fontMono.widthOfTextAtSize(amtStr, sz);