mirror of
https://github.com/kbenestad/reimburse.git
synced 2026-06-18 08:04:31 +00:00
Compare commits
11 commits
2482cf544f
...
b1efad2ab4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b1efad2ab4 | ||
|
|
43a7638b12 | ||
|
|
08a0d2b0ea | ||
|
|
6787fd15a2 | ||
|
|
364cac2155 | ||
|
|
076996a470 | ||
|
|
013563d13e | ||
|
|
e371505323 | ||
|
|
0a84ba4628 | ||
|
|
3f7fcf47ed | ||
|
|
345e285d30 |
1 changed files with 338 additions and 173 deletions
511
app/index.html
511
app/index.html
|
|
@ -111,7 +111,7 @@ body{
|
|||
.kb-wrap{max-width:960px;margin:0 auto;padding:24px 20px 56px;}
|
||||
|
||||
/* ── Toolbar ──────────────────────────────────────────────────────────────── */
|
||||
.kb-toolbar{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:16px;}
|
||||
.kb-toolbar{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:16px;}
|
||||
.kb-toolbar .spacer{flex:1;}
|
||||
.kb-seg{
|
||||
display:inline-flex;align-items:center;gap:2px;
|
||||
|
|
@ -332,12 +332,28 @@ body{
|
|||
.kb-footer a:hover{color:var(--accent);text-decoration:underline;}
|
||||
.kb-footer .sep{opacity:.45;}
|
||||
|
||||
/* ── App wordmark (toolbar left) ──────────────────────────────────────────── */
|
||||
.kb-doctitle h1{display:inline-flex;align-items:center;gap:9px;}
|
||||
.kb-doctitle h1 svg{flex-shrink:0;}
|
||||
/* Language select in toolbar */
|
||||
.kb-toolbar select{
|
||||
font:600 var(--fs-small)/1 var(--font-sans);color:var(--text-muted);
|
||||
background:var(--surface);border:1px solid var(--border);
|
||||
border-radius:var(--radius-sm);padding:5px 28px 5px 10px;
|
||||
outline:none;appearance:none;cursor:pointer;
|
||||
background-image:url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 16 16' fill='none' stroke='%235F6975' stroke-width='1.8'><path d='M4 6l4 4 4-4'/></svg>");
|
||||
background-repeat:no-repeat;background-position:right 8px center;
|
||||
transition:border-color .14s,color .14s;
|
||||
}
|
||||
.kb-toolbar select:focus{border-color:var(--accent);box-shadow:var(--ring);color:var(--text);}
|
||||
.kb-toolbar select:hover{border-color:var(--accent-border);color:var(--text);}
|
||||
|
||||
/* ── Loading ──────────────────────────────────────────────────────────────── */
|
||||
.kb-loading{text-align:center;padding:80px;color:var(--text-muted);}
|
||||
|
||||
/* ── Responsive ───────────────────────────────────────────────────────────── */
|
||||
@media (max-width:680px){
|
||||
.kb-grid.cols-2,.kb-grid.cols-3,.kb-grid.cols-4{grid-template-columns:1fr;}
|
||||
.kb-grid.cols-2,.kb-grid.cols-3,.kb-grid.cols-4,.kb-grid.claim-grid{grid-template-columns:1fr;}
|
||||
.kb-header{flex-direction:column;gap:14px;}
|
||||
.kb-doctitle{text-align:left;}
|
||||
.kb-line-section.row1,.kb-line-section.row2,.kb-line-section.row3{grid-template-columns:1fr;}
|
||||
|
|
@ -345,6 +361,7 @@ body{
|
|||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div id="app"><p class="kb-loading">Loading configuration…</p></div>
|
||||
<footer class="kb-footer">
|
||||
<span>© 2026 Kristian Benestad</span>
|
||||
|
|
@ -502,7 +519,7 @@ function newItem() { return { id: uid(), name: '', lines: [newLine()], _subtotal
|
|||
function newLine() {
|
||||
return {
|
||||
id: uid(), date: state.periodFrom || '', description: '', currency: state.baseCurrency,
|
||||
fxRate: '1.00000', vendor: '', hasReceipt: true, receipts: [],
|
||||
fxRate: '1.00000', fxDir: 'foreign', vendor: '', hasReceipt: true, receipts: [],
|
||||
noReceiptExplanation: '', amount: '', account: '', customCurrency: false,
|
||||
programs: [{ program: '', percent: '', programOther: '' }]
|
||||
};
|
||||
|
|
@ -544,7 +561,7 @@ async function saveState() {
|
|||
id: item.id, name: item.name,
|
||||
lines: item.lines.map(ln => ({
|
||||
id: ln.id, date: ln.date, description: ln.description,
|
||||
currency: ln.currency, fxRate: ln.fxRate, vendor: ln.vendor,
|
||||
currency: ln.currency, fxRate: ln.fxRate, fxDir: ln.fxDir || 'foreign', vendor: ln.vendor,
|
||||
hasReceipt: ln.hasReceipt, noReceiptExplanation: ln.noReceiptExplanation,
|
||||
amount: ln.amount, account: ln.account, customCurrency: ln.customCurrency || false,
|
||||
programs: ln.programs,
|
||||
|
|
@ -580,7 +597,7 @@ async function loadState() {
|
|||
for (const ld of (id.lines || [])) {
|
||||
const ln = {
|
||||
id: ld.id, date: ld.date, description: ld.description,
|
||||
currency: ld.currency, fxRate: ld.fxRate, vendor: ld.vendor,
|
||||
currency: ld.currency, fxRate: ld.fxRate, fxDir: ld.fxDir || 'foreign', vendor: ld.vendor,
|
||||
hasReceipt: ld.hasReceipt, receipts: [],
|
||||
noReceiptExplanation: ld.noReceiptExplanation,
|
||||
amount: ld.amount, account: ld.account,
|
||||
|
|
@ -685,6 +702,18 @@ function render() {
|
|||
|
||||
// ── Toolbar ──────────────────────────────────────────────────────────────
|
||||
const toolbar = el('div', {className:'kb-toolbar'});
|
||||
|
||||
// Language selector (only when CFG.languages has >1 entry) — goes first
|
||||
if (Array.isArray(CFG.languages) && CFG.languages.length > 1) {
|
||||
const langSel = el('select', {'aria-label':'Language'});
|
||||
CFG.languages.forEach(lang => {
|
||||
const code = typeof lang === 'object' ? lang.code : lang;
|
||||
const name = typeof lang === 'object' ? lang.name : lang;
|
||||
langSel.appendChild(el('option', {value: code}, name));
|
||||
});
|
||||
toolbar.appendChild(langSel);
|
||||
}
|
||||
|
||||
toolbar.appendChild(el('div', {className:'spacer'}));
|
||||
|
||||
// Font size
|
||||
|
|
@ -702,30 +731,25 @@ function render() {
|
|||
});
|
||||
toolbar.appendChild(sizeSeg);
|
||||
|
||||
// Theme toggle
|
||||
const themeSeg = el('div', {className:'kb-seg', role:'group', 'aria-label':'Colour theme'});
|
||||
const savedTheme = localStorage.getItem('reimb-theme');
|
||||
[{lbl:'Auto', val:null}, {lbl:'Light', val:'light'}, {lbl:'Dark', val:'dark'}].forEach(t => {
|
||||
const isActive = savedTheme === t.val || (!savedTheme && t.val === null);
|
||||
const btn = el('button', {type:'button', className: isActive ? 'is-active' : ''}, t.lbl);
|
||||
btn.addEventListener('click', () => {
|
||||
$$('button', themeSeg).forEach(b => b.classList.remove('is-active'));
|
||||
btn.classList.add('is-active');
|
||||
if (t.val) {
|
||||
document.documentElement.setAttribute('data-theme', t.val);
|
||||
localStorage.setItem('reimb-theme', t.val);
|
||||
} else {
|
||||
document.documentElement.removeAttribute('data-theme');
|
||||
localStorage.removeItem('reimb-theme');
|
||||
}
|
||||
});
|
||||
themeSeg.appendChild(btn);
|
||||
// Theme toggle (single icon button: moon = light mode, sun = dark mode)
|
||||
const themeBtn = el('button', {className:'kb-iconbtn', type:'button', 'aria-label':'Toggle theme'});
|
||||
const moonSVG = `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"/></svg>`;
|
||||
const sunSVG = `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41"/></svg>`;
|
||||
const currentTheme = () => document.documentElement.getAttribute('data-theme') ||
|
||||
(window.matchMedia('(prefers-color-scheme:dark)').matches ? 'dark' : 'light');
|
||||
const updateThemeBtn = () => { themeBtn.innerHTML = currentTheme() === 'dark' ? sunSVG : moonSVG; };
|
||||
updateThemeBtn();
|
||||
themeBtn.addEventListener('click', () => {
|
||||
const next = currentTheme() === 'dark' ? 'light' : 'dark';
|
||||
document.documentElement.setAttribute('data-theme', next);
|
||||
localStorage.setItem('reimb-theme', next);
|
||||
updateThemeBtn();
|
||||
});
|
||||
toolbar.appendChild(themeSeg);
|
||||
toolbar.appendChild(themeBtn);
|
||||
|
||||
// About
|
||||
const aboutBtn = el('button', {className:'kb-iconbtn', type:'button', 'aria-label':'About', onClick: showAboutModal});
|
||||
aboutBtn.innerHTML = `<svg viewBox="0 0 16 16" fill="currentColor" width="16" height="16"><path d="M8 0a8 8 0 1 1 0 16A8 8 0 0 1 8 0zm.9 6.8H7.1v5.4h1.8V6.8zM8 3.3a1.1 1.1 0 1 0 0 2.2 1.1 1.1 0 0 0 0-2.2z"/></svg>`;
|
||||
aboutBtn.innerHTML = `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4M12 8h.01"/></svg>`;
|
||||
toolbar.appendChild(aboutBtn);
|
||||
wrap.appendChild(toolbar);
|
||||
|
||||
|
|
@ -751,11 +775,23 @@ function render() {
|
|||
orgSpan.appendChild(el('small', null, 'Expense reimbursement'));
|
||||
brand.append(logoBox, orgSpan);
|
||||
|
||||
const now = new Date();
|
||||
const dateStr = now.toLocaleDateString('en-GB', {day:'numeric', month:'long', year:'numeric'});
|
||||
const docTitle = el('div', {className:'kb-doctitle'});
|
||||
docTitle.appendChild(el('h1', null, 'Reimbursement'));
|
||||
docTitle.appendChild(el('div', {className:'meta'}, `Claim · ${dateStr}`));
|
||||
const h1 = el('h1');
|
||||
h1.innerHTML = `<svg width="24" height="24" viewBox="0 0 48 48" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><rect width="48" height="48" rx="12" fill="var(--accent)"/><rect x="7.5" y="14" width="33" height="20" rx="3.2" fill="none" stroke="#fff" stroke-width="2.8"/><circle cx="24" cy="24" r="4.6" fill="none" stroke="#fff" stroke-width="2.6"/><path d="M12.7 21.4V26.6M35.3 21.4V26.6" stroke="#fff" stroke-width="2.6" stroke-linecap="round"/></svg>reimburse`;
|
||||
const periodMeta = el('div', {className:'meta'});
|
||||
function fmtPeriodDate(iso) {
|
||||
const [y, m, d] = iso.split('-').map(Number);
|
||||
return `${d} ${new Date(y, m-1, 1).toLocaleString('en-GB', {month:'long'})}`;
|
||||
}
|
||||
function updatePeriodMeta() {
|
||||
const from = state.periodFrom, to = state.periodTo;
|
||||
if (from && to) periodMeta.textContent = `${fmtPeriodDate(from)} – ${fmtPeriodDate(to)}`;
|
||||
else if (from) periodMeta.textContent = `From ${fmtPeriodDate(from)}`;
|
||||
else if (to) periodMeta.textContent = `Until ${fmtPeriodDate(to)}`;
|
||||
else periodMeta.textContent = '';
|
||||
}
|
||||
updatePeriodMeta();
|
||||
docTitle.append(h1, periodMeta);
|
||||
hdr.append(brand, docTitle);
|
||||
wrap.appendChild(hdr);
|
||||
|
||||
|
|
@ -769,17 +805,13 @@ function render() {
|
|||
const staffInput = el('input', {className:'kb-input', type:'text', value: state.staff, placeholder:'Full name'});
|
||||
staffInput.addEventListener('input', () => { state.staff = staffInput.value; localStorage.setItem('reimb-staff', staffInput.value); });
|
||||
|
||||
const newFormBtn = el('button', {type:'button', className:'kb-btn kb-btn--ghost', style:{flexShrink:'0'}, onClick: onNewForm}, 'New form');
|
||||
const staffWrap = el('div', {style:{display:'flex', gap:'8px', alignItems:'center'}});
|
||||
staffWrap.append(staffInput, newFormBtn);
|
||||
|
||||
const fromInput = el('input', {className:'kb-input', type:'date'});
|
||||
fromInput.value = state.periodFrom;
|
||||
fromInput.addEventListener('change', () => { state.periodFrom = fromInput.value; });
|
||||
fromInput.addEventListener('change', () => { state.periodFrom = fromInput.value; updatePeriodMeta(); });
|
||||
|
||||
const toInput = el('input', {className:'kb-input', type:'date'});
|
||||
toInput.value = state.periodTo;
|
||||
toInput.addEventListener('change', () => { state.periodTo = toInput.value; });
|
||||
toInput.addEventListener('change', () => { state.periodTo = toInput.value; updatePeriodMeta(); });
|
||||
|
||||
const baseCurDD = makeCDD(CFG.currencies || [], state.baseCurrency, code => {
|
||||
state.baseCurrency = code;
|
||||
|
|
@ -790,15 +822,15 @@ function render() {
|
|||
|
||||
const claimCard = el('div', {className:'kb-card'});
|
||||
claimCard.appendChild(el('h2', {className:'kb-card__title'}, 'Claimant'));
|
||||
const claimGrid = el('div', {className:'kb-grid cols-3'});
|
||||
claimGrid.appendChild(kbField('Staff', staffWrap, {cls:''}));
|
||||
// Period from + to span col 2-3
|
||||
const periodGroup = el('div', {style:{display:'grid', gridTemplateColumns:'1fr 1fr', gap:'14px 16px', gridColumn:'span 2'}});
|
||||
periodGroup.appendChild(kbField('Period from', fromInput));
|
||||
periodGroup.appendChild(kbField('Period to', toInput));
|
||||
claimGrid.appendChild(periodGroup);
|
||||
const claimGrid = el('div', {className:'kb-grid claim-grid', style:{gridTemplateColumns:'2fr 0.85fr 0.85fr 0.5fr'}});
|
||||
claimGrid.appendChild(kbField('Staff', staffInput));
|
||||
claimGrid.appendChild(kbField('Period from', fromInput));
|
||||
claimGrid.appendChild(kbField('Period to', toInput));
|
||||
claimGrid.appendChild(kbField('Base currency', baseCurDD));
|
||||
claimCard.appendChild(claimGrid);
|
||||
|
||||
const newFormBtn = el('button', {type:'button', className:'kb-btn kb-btn--ghost', style:{marginTop:'12px'}, onClick: onNewForm}, 'New form');
|
||||
claimCard.appendChild(newFormBtn);
|
||||
wrap.appendChild(claimCard);
|
||||
|
||||
// ── Expenses card ─────────────────────────────────────────────────────────
|
||||
|
|
@ -902,15 +934,58 @@ function renderLine(ln, item) {
|
|||
const vendIn = el('input', {className:'kb-input', type:'text', value: ln.vendor, placeholder:'Vendor name'});
|
||||
vendIn.addEventListener('input', () => { ln.vendor = vendIn.value; });
|
||||
|
||||
// FX
|
||||
const fxIn = el('input', {className:'kb-input num', type:'text', placeholder:'0.00000', style:{minWidth:'0'}});
|
||||
fxIn.value = ln.fxRate;
|
||||
// FX — bidirectional rate entry
|
||||
// Canonical: ln.fxRate is always "foreign per 1 base" (divide to get base amount).
|
||||
// ln.fxDir controls display direction: 'foreign' = show canonical, 'base' = show 1/canonical.
|
||||
const fxIn = el('input', {className:'kb-input num', type:'text', placeholder:'0.00000', style:{minWidth:'0', width:'90px'}});
|
||||
const fxDirContainer = el('div', {style:{display:'inline-block'}});
|
||||
const fxPerLbl = el('span', {style:{fontSize:'var(--fs-small)', color:'var(--text-muted)', whiteSpace:'nowrap', userSelect:'none'}});
|
||||
const fxWrap = el('div', {style:{display:'flex', alignItems:'center', gap:'4px'}});
|
||||
fxWrap.append(fxIn, fxDirContainer, fxPerLbl);
|
||||
|
||||
let fxDirDD = null;
|
||||
|
||||
function fxDisplayVal() {
|
||||
if (!ln.fxRate || ln.fxRate === '' || ln.fxRate === '1.00000') return ln.fxRate;
|
||||
const r = parseFloat(ln.fxRate);
|
||||
if (ln.fxDir === 'base' && r > 0) return (1 / r).toFixed(5);
|
||||
return ln.fxRate;
|
||||
}
|
||||
function fxStoreFromDisplay(displayVal) {
|
||||
const v = parseFloat(displayVal);
|
||||
if (isNaN(v) || v <= 0) { ln.fxRate = displayVal; return; }
|
||||
ln.fxRate = ln.fxDir === 'base' ? (1 / v).toFixed(5) : displayVal;
|
||||
}
|
||||
function rebuildFxDirSel() {
|
||||
const fcy = ln.currency || '???';
|
||||
const initCode = ln.fxDir === 'base' ? baseCur : fcy;
|
||||
const opts = [
|
||||
{code: fcy, name: getCurrencyName(fcy) || fcy},
|
||||
{code: baseCur, name: getCurrencyName(baseCur) || baseCur}
|
||||
];
|
||||
fxDirDD = makeCDD(opts, initCode, code => {
|
||||
ln.fxDir = code === baseCur ? 'base' : 'foreign';
|
||||
fxIn.value = fxDisplayVal();
|
||||
fxPerLbl.textContent = 'per ' + (ln.fxDir === 'foreign' ? baseCur : ln.currency || '???');
|
||||
});
|
||||
fxDirContainer.innerHTML = '';
|
||||
fxDirContainer.appendChild(fxDirDD);
|
||||
fxPerLbl.textContent = 'per ' + (ln.fxDir === 'foreign' ? baseCur : fcy);
|
||||
}
|
||||
function showFxControls(show) {
|
||||
fxDirContainer.style.display = show ? '' : 'none';
|
||||
fxPerLbl.style.display = show ? '' : 'none';
|
||||
}
|
||||
|
||||
fxIn.value = fxDisplayVal();
|
||||
fxIn.readOnly = !ln.customCurrency && ln.currency === baseCur;
|
||||
if (fxIn.readOnly) fxIn.className = 'kb-input num';
|
||||
rebuildFxDirSel();
|
||||
showFxControls(ln.currency !== baseCur);
|
||||
|
||||
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;
|
||||
fxStoreFromDisplay(fxIn.value);
|
||||
const rate = parseFloat(ln.fxRate);
|
||||
if (ln.currency && ln.currency !== baseCur && rate > 0) state.fxRateMemory[ln.currency] = ln.fxRate;
|
||||
recalc();
|
||||
if (progAreaEl) progAreaEl._refresh();
|
||||
});
|
||||
|
|
@ -927,12 +1002,17 @@ function renderLine(ln, item) {
|
|||
|
||||
function applyFxCurrency(code) {
|
||||
if (code === baseCur) {
|
||||
ln.fxRate = '1.00000'; fxIn.value = '1.00000'; fxIn.readOnly = true;
|
||||
ln.fxRate = '1.00000'; ln.fxDir = 'foreign';
|
||||
fxIn.value = '1.00000'; fxIn.readOnly = true;
|
||||
showFxControls(false);
|
||||
} 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 = ''; }
|
||||
if (mem) { ln.fxRate = mem; }
|
||||
else if (ln.fxRate === '1.00000') { ln.fxRate = ''; }
|
||||
rebuildFxDirSel();
|
||||
fxIn.value = fxDisplayVal();
|
||||
showFxControls(true);
|
||||
}
|
||||
recalc();
|
||||
if (progAreaEl) progAreaEl._refresh();
|
||||
|
|
@ -988,7 +1068,7 @@ function renderLine(ln, item) {
|
|||
kbField('Date', dateIn),
|
||||
kbField('Vendor', vendIn),
|
||||
kbField('Currency', curWrap),
|
||||
kbField('FX rate', fxIn)
|
||||
kbField('FX rate', fxWrap)
|
||||
);
|
||||
blk.appendChild(row1);
|
||||
|
||||
|
|
@ -1207,22 +1287,28 @@ async function generatePDF() {
|
|||
const doc = await PDFDocument.create();
|
||||
const pageW = CFG['page-size'] === 'letter' ? 612 : 595.28;
|
||||
const pageH = CFG['page-size'] === 'letter' ? 792 : 841.89;
|
||||
const M = { top: 50, bottom: 65, left: 50, right: 50 };
|
||||
const M = { top: 52, bottom: 65, left: 48, right: 48 };
|
||||
const W = pageW - M.left - M.right;
|
||||
|
||||
const fontBody = await doc.embedFont(StandardFonts.Helvetica);
|
||||
const fontBold = await doc.embedFont(StandardFonts.HelveticaBold);
|
||||
const fontMono = await doc.embedFont(StandardFonts.Courier);
|
||||
const sz = CFG['font-size'] || 10;
|
||||
const sz = CFG['font-size'] || 10;
|
||||
const szSm = sz - 1;
|
||||
const szLg = sz + 4;
|
||||
const szXl = sz + 6;
|
||||
const lh = sz + 4;
|
||||
const szXs = Math.max(sz - 2, 7); // eyebrow labels
|
||||
const szLg = sz + 4; // section headings, grand total figure
|
||||
const lh = sz + 5; // line height
|
||||
|
||||
// ── kBenestad color tokens ────────────────────────────────────────────────
|
||||
const clrAccent = parseHex(CFG['accent-colour'] || '#2F6FED');
|
||||
const clrText = rgb(0.078, 0.094, 0.118); // #14181E
|
||||
const clrTextSoft = rgb(0.227, 0.263, 0.310); // #3A434F
|
||||
const clrMuted = rgb(0.373, 0.412, 0.459); // #5F6975
|
||||
const clrBorder = rgb(0.890, 0.918, 0.933); // #E3E7EE
|
||||
const clrBorderStrong = rgb(0.827, 0.851, 0.886); // #D3D9E2
|
||||
const clrSurface2 = rgb(0.973, 0.976, 0.984); // #F8F9FB
|
||||
const clrWarn = rgb(0.788, 0.318, 0.000); // out-of-period date
|
||||
|
||||
const accent = parseHex(CFG['accent-colour'] || '#2F6FED');
|
||||
const black = rgb(0.13, 0.13, 0.13);
|
||||
const gray = rgb(0.45, 0.45, 0.45);
|
||||
const lineCol = rgb(0.75, 0.75, 0.75);
|
||||
const baseCur = state.baseCurrency;
|
||||
|
||||
let logoImage = null;
|
||||
|
|
@ -1233,6 +1319,26 @@ async function generatePDF() {
|
|||
const receiptRefs = [];
|
||||
let justBroke = false;
|
||||
|
||||
// ── Drawing helpers ───────────────────────────────────────────────────────
|
||||
|
||||
// Eyebrow label: uppercase, bold, muted, szXs
|
||||
function lbl(text, x, yy) {
|
||||
pg.drawText(text.toUpperCase(), { x, y: yy, size: szXs, font: fontBold, color: clrMuted });
|
||||
}
|
||||
// Body value in Helvetica
|
||||
function val(text, x, yy, extra) {
|
||||
pg.drawText(text, { x, y: yy, size: sz, font: fontBody, color: clrText, ...(extra||{}) });
|
||||
}
|
||||
// Mono value (amounts, dates, FX rates) in Courier
|
||||
function mono(text, x, yy, extra) {
|
||||
pg.drawText(text, { x, y: yy, size: sz, font: fontMono, color: clrText, ...(extra||{}) });
|
||||
}
|
||||
// 3pt accent left-stripe for item/section headers
|
||||
function accentStripe(yy) {
|
||||
pg.drawRectangle({ x: M.left, y: yy - 1, width: 3, height: lh + 1, color: clrAccent });
|
||||
}
|
||||
|
||||
// ── Page management ───────────────────────────────────────────────────────
|
||||
function addPage(isFirst) {
|
||||
pg = doc.addPage([pageW, pageH]);
|
||||
pages.push(pg);
|
||||
|
|
@ -1246,156 +1352,216 @@ async function generatePDF() {
|
|||
if (y - h < M.bottom) addPage(false);
|
||||
}
|
||||
|
||||
// Continuation header: light strip with staff + period
|
||||
function drawContHeader() {
|
||||
pg.drawText(state.staff, { x: M.left, y, size: sz, font: fontBold, color: black });
|
||||
const periodStr = `Period: ${state.periodFrom} to ${state.periodTo}`;
|
||||
const pw = fontBody.widthOfTextAtSize(periodStr, sz);
|
||||
pg.drawText(periodStr, { x: M.left + W - pw, y, size: sz, font: fontBody, color: gray });
|
||||
y -= lh + 2;
|
||||
pg.drawLine({ start:{x:M.left, y}, end:{x:M.left+W, y}, thickness:1.5, color:accent });
|
||||
y -= lh;
|
||||
const stripH = szXs + lh + 14;
|
||||
pg.drawRectangle({ x: 0, y: y - stripH, width: pageW, height: stripH, color: clrSurface2 });
|
||||
pg.drawLine({ start:{x:0, y}, end:{x:pageW, y}, thickness:0.5, color:clrBorder });
|
||||
pg.drawLine({ start:{x:0, y:y-stripH}, end:{x:pageW, y:y-stripH}, thickness:0.5, color:clrBorder });
|
||||
|
||||
const labY = y - 7;
|
||||
const valY = labY - szXs - 5;
|
||||
lbl('Staff', M.left, labY);
|
||||
lbl('Period', M.left + W * 0.45, labY);
|
||||
pg.drawText(state.staff, { x:M.left, y:valY, size:sz, font:fontBold, color:clrText });
|
||||
mono(`${state.periodFrom} to ${state.periodTo}`, M.left + W * 0.45, valY);
|
||||
|
||||
y -= stripH + 12;
|
||||
}
|
||||
|
||||
// ── Page 1 ────────────────────────────────────────────────────────────────
|
||||
addPage(true);
|
||||
|
||||
const mm10 = 10 * 2.83465;
|
||||
if (logoImage) {
|
||||
const maxW = (CFG['logo-maxwidth'] || 4) * 28.3465;
|
||||
const scale = Math.min(maxW / logoImage.width, 50 / logoImage.height, 1);
|
||||
const lw = logoImage.width * scale, lhh = logoImage.height * scale;
|
||||
const logoTop = pageH - mm10;
|
||||
pg.drawImage(logoImage, { x: mm10, y: logoTop - lhh, width: lw, height: lhh });
|
||||
y = Math.min(y, logoTop - lhh - 8);
|
||||
} else if (CFG.organization) {
|
||||
pg.drawText(CFG.organization, { x: M.left, y, size: szLg, font: fontBold, color: accent });
|
||||
y -= szLg + 8;
|
||||
}
|
||||
const titleStr = 'REIMBURSEMENT FORM';
|
||||
const tw = fontBold.widthOfTextAtSize(titleStr, szLg);
|
||||
pg.drawText(titleStr, { x: M.left + W - tw, y, size: szLg, font: fontBold, color: accent });
|
||||
y -= szLg + 8;
|
||||
// Header: brand block left | doc title right
|
||||
const hdrTopY = y;
|
||||
const boxSize = 44;
|
||||
|
||||
if (logoImage) {
|
||||
const maxWLogo = (CFG['logo-maxwidth'] || 4) * 28.3465;
|
||||
const scale = Math.min(maxWLogo / logoImage.width, boxSize / logoImage.height, 1);
|
||||
const lw = logoImage.width * scale, lhImg = logoImage.height * scale;
|
||||
pg.drawImage(logoImage, { x: M.left, y: hdrTopY - lhImg, width: lw, height: lhImg });
|
||||
} else {
|
||||
// Initials box (surface-2 fill, border, accent initials)
|
||||
const raw = (CFG.organization || 'ORG').trim();
|
||||
const initials = raw.split(/\s+/).map(w => w[0] || '').join('').toUpperCase().slice(0, 2) || '??';
|
||||
pg.drawRectangle({ x: M.left, y: hdrTopY - boxSize, width: boxSize, height: boxSize,
|
||||
color: clrSurface2, borderColor: clrBorder, borderWidth: 0.5 });
|
||||
const initW = fontBold.widthOfTextAtSize(initials, szLg);
|
||||
pg.drawText(initials, { x: M.left + (boxSize - initW) / 2, y: hdrTopY - boxSize / 2 - szLg * 0.35,
|
||||
size: szLg, font: fontBold, color: clrAccent });
|
||||
}
|
||||
|
||||
// Org name + subtitle
|
||||
const orgX = M.left + boxSize + 11;
|
||||
pg.drawText(CFG.organization || '', { x: orgX, y: hdrTopY - 15, size: sz + 2, font: fontBold, color: clrText });
|
||||
pg.drawText('Expense reimbursement', { x: orgX, y: hdrTopY - 15 - (sz + 2) - 4, size: szSm, font: fontBody, color: clrMuted });
|
||||
|
||||
// Title + claim date (right-aligned)
|
||||
const titleStr = 'Reimbursement';
|
||||
const titleW = fontBold.widthOfTextAtSize(titleStr, szLg);
|
||||
pg.drawText(titleStr, { x: M.left + W - titleW, y: hdrTopY - 15, size: szLg, font: fontBold, color: clrText });
|
||||
|
||||
const nowD = new Date();
|
||||
const MONTHS = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
|
||||
const claimDate = `Claim · ${nowD.getDate()} ${MONTHS[nowD.getMonth()]} ${nowD.getFullYear()}`;
|
||||
const claimDateW = fontMono.widthOfTextAtSize(claimDate, szSm);
|
||||
pg.drawText(claimDate, { x: M.left + W - claimDateW, y: hdrTopY - 15 - szLg - 4, size: szSm, font: fontMono, color: clrMuted });
|
||||
|
||||
y = hdrTopY - boxSize - 10;
|
||||
|
||||
// Hairline below header
|
||||
pg.drawLine({ start:{x:M.left, y}, end:{x:M.left+W, y}, thickness:0.5, color:clrBorder });
|
||||
y -= 12;
|
||||
|
||||
// Intro text
|
||||
if (CFG.intro) {
|
||||
const introLines = wrapText(CFG.intro, fontBody, sz, W);
|
||||
introLines.forEach(line => { pg.drawText(line, {x:M.left, y, size:sz, font:fontBody, color:gray}); y -= lh; });
|
||||
y -= 4;
|
||||
introLines.forEach(line => { pg.drawText(line, {x:M.left, y, size:sz, font:fontBody, color:clrMuted}); y -= lh; });
|
||||
y -= 6;
|
||||
}
|
||||
|
||||
const col2 = W * 0.5;
|
||||
const col3 = W * 0.8;
|
||||
// Staff / Period / Currency info block
|
||||
const infoH = szXs + lh + 14;
|
||||
pg.drawRectangle({ x: M.left - 8, y: y - infoH, width: W + 16, height: infoH, color: clrSurface2 });
|
||||
pg.drawLine({ start:{x:M.left-8, y}, end:{x:M.left+W+8, y}, thickness:0.5, color:clrBorder });
|
||||
pg.drawLine({ start:{x:M.left-8, y:y-infoH}, end:{x:M.left+W+8, y:y-infoH}, thickness:0.5, color:clrBorder });
|
||||
|
||||
pg.drawText('Staff', {x:M.left, y, size:szSm-1, font:fontBold, color:gray});
|
||||
pg.drawText('Period', {x:M.left+col2, y, size:szSm-1, font:fontBold, color:gray});
|
||||
pg.drawText('Currency', {x:M.left+col3, y, size:szSm-1, font:fontBold, color:gray});
|
||||
y -= lh;
|
||||
pg.drawText(state.staff, {x:M.left, y, size:sz, font:fontBody, color:black});
|
||||
pg.drawText(`${state.periodFrom} to ${state.periodTo}`, {x:M.left+col2, y, size:sz, font:fontBody, color:black});
|
||||
pg.drawText(baseCur, {x:M.left+col3, y, size:sz, font:fontBold, color:black});
|
||||
y -= lh + 6;
|
||||
const iLabY = y - 7;
|
||||
const iValY = iLabY - szXs - 5;
|
||||
const iC2 = W * 0.45, iC3 = W * 0.78;
|
||||
|
||||
pg.drawLine({start:{x:M.left,y}, end:{x:M.left+W,y}, thickness:1.5, color:accent});
|
||||
y -= lh;
|
||||
lbl('Staff', M.left, iLabY);
|
||||
lbl('Period', M.left + iC2, iLabY);
|
||||
lbl('Currency', M.left + iC3, iLabY);
|
||||
|
||||
pg.drawText(state.staff, { x: M.left, y: iValY, size: sz, font: fontBold, color: clrText });
|
||||
mono(`${state.periodFrom} to ${state.periodTo}`, M.left + iC2, iValY);
|
||||
pg.drawText(baseCur, { x: M.left + iC3, y: iValY, size: sz, font: fontBold, color: clrAccent });
|
||||
|
||||
y -= infoH + 14;
|
||||
|
||||
// ── Items ─────────────────────────────────────────────────────────────────
|
||||
state.items.forEach(item => {
|
||||
needSpace(lh * 6);
|
||||
pg.drawText('ITEM / PROJECT / TRAVEL', {x:M.left, y, size:szSm, font:fontBold, color:accent});
|
||||
const subStr = `Subtotal: ${baseCur} ${fmtAmt(item._subtotal)}`;
|
||||
const subW = fontBold.widthOfTextAtSize(subStr, sz);
|
||||
pg.drawText(subStr, {x:M.left+W-subW, y, size:sz, font:fontBold, color:accent});
|
||||
y -= lh;
|
||||
pg.drawText(item.name, {x:M.left, y, size:sz, font:fontBody, color:black});
|
||||
y -= lh + 4;
|
||||
needSpace(lh * 7);
|
||||
|
||||
// Section header: accent stripe + item name left, subtotal right
|
||||
accentStripe(y);
|
||||
pg.drawText(item.name || '(no name)', { x: M.left + 8, y, size: sz, font: fontBold, color: clrTextSoft });
|
||||
const subStr = `${baseCur} ${fmtAmt(item._subtotal)}`;
|
||||
const subW = fontMono.widthOfTextAtSize(subStr, sz);
|
||||
pg.drawText(subStr, { x: M.left + W - subW, y, size: sz, font: fontMono, color: clrAccent });
|
||||
y -= lh + 2;
|
||||
|
||||
// Hairline below section header
|
||||
pg.drawLine({ start:{x:M.left, y}, end:{x:M.left+W, y}, thickness:0.5, color:clrBorder });
|
||||
y -= 8;
|
||||
|
||||
// Lines
|
||||
item.lines.forEach((ln, li) => {
|
||||
needSpace(lh * 7);
|
||||
needSpace(lh * 9);
|
||||
if (li > 0 && !justBroke) {
|
||||
y -= 4;
|
||||
pg.drawLine({start:{x:M.left,y}, end:{x:M.left+W,y}, thickness:0.3, color:lineCol});
|
||||
pg.drawLine({ start:{x:M.left, y}, end:{x:M.left+W, y}, thickness:0.3, color:clrBorder });
|
||||
y -= 8;
|
||||
}
|
||||
const c1=0, c2=W*0.22, c3=W*0.68, c4=W*0.82;
|
||||
|
||||
pg.drawText('Date', {x:M.left+c1, 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});
|
||||
const fxLbl = 'FX rate'; pg.drawText(fxLbl, {x:M.left+W-fontBold.widthOfTextAtSize(fxLbl,szSm-1), y, size:szSm-1, font:fontBold, color:gray});
|
||||
y -= lh;
|
||||
const c1=0, c2=W*0.22, c3=W*0.64;
|
||||
|
||||
// Row 1 — Date | Vendor | Currency | FX rate
|
||||
lbl('Date', M.left + c1, y);
|
||||
lbl('Vendor', M.left + c2, y);
|
||||
lbl('Currency', M.left + c3, y);
|
||||
const fxLblStr = 'FX RATE';
|
||||
pg.drawText(fxLblStr, { x: M.left + W - fontBold.widthOfTextAtSize(fxLblStr, szXs), y,
|
||||
size: szXs, font: fontBold, color: clrMuted });
|
||||
y -= szXs + 4;
|
||||
|
||||
const dateInPeriod = isDateInPeriod(ln.date);
|
||||
const dateColor = dateInPeriod ? black : rgb(0.9, 0.33, 0);
|
||||
pg.drawText((ln.date || '–') + (dateInPeriod ? '' : ' (!)'), {x:M.left+c1, y, size:sz, font:fontBody, color:dateColor});
|
||||
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});
|
||||
mono((ln.date || '–') + (dateInPeriod ? '' : ' (!)'), M.left + c1, y,
|
||||
{ color: dateInPeriod ? clrText : clrWarn });
|
||||
val(truncate(ln.vendor, fontBody, sz, (c3 - c2) - 8), M.left + c2, y);
|
||||
val(ln.currency || '–', M.left + c3, y);
|
||||
const fxStr = ln.currency === baseCur ? '–' : parseFloat(ln.fxRate).toFixed(5);
|
||||
const fxW = fontBody.widthOfTextAtSize(fxStr, sz);
|
||||
pg.drawText(fxStr, {x:M.left+W-fxW, y, size:sz, font:fontBody, color:black});
|
||||
y -= lh + 2;
|
||||
const fxW = fontMono.widthOfTextAtSize(fxStr, sz);
|
||||
mono(fxStr, M.left + W - fxW, y);
|
||||
y -= lh + 4;
|
||||
|
||||
pg.drawText('Description', {x:M.left, y, size:szSm-1, font:fontBold, color:gray});
|
||||
pg.drawText('Receipt', {x:M.left+c3, y, size:szSm-1, font:fontBold, color:gray});
|
||||
const amtLbl = 'Amount'; pg.drawText(amtLbl, {x:M.left+W-fontBold.widthOfTextAtSize(amtLbl,szSm-1), y, size:szSm-1, font:fontBold, color:gray});
|
||||
y -= lh;
|
||||
pg.drawText(truncate(ln.description, fontBody, sz, (c3)-8), {x:M.left, y, size:sz, font:fontBody, color:black});
|
||||
pg.drawText(ln.hasReceipt ? 'Yes' : 'No', {x:M.left+c3, y, size:sz, font:fontBody, color:black});
|
||||
// Row 2 — Description | Receipt | Amount
|
||||
lbl('Description', M.left, y);
|
||||
lbl('Receipt', M.left + c3, y);
|
||||
const amtLblStr = 'AMOUNT';
|
||||
pg.drawText(amtLblStr, { x: M.left + W - fontBold.widthOfTextAtSize(amtLblStr, szXs), y,
|
||||
size: szXs, font: fontBold, color: clrMuted });
|
||||
y -= szXs + 4;
|
||||
|
||||
val(truncate(ln.description, fontBody, sz, c3 - 8), M.left, y);
|
||||
val(ln.hasReceipt ? 'Yes' : 'No', M.left + c3, y);
|
||||
const amtStr = `${ln.currency} ${fmtAmt(ln.amount)}`;
|
||||
const amtW = fontBody.widthOfTextAtSize(amtStr, sz);
|
||||
pg.drawText(amtStr, {x:M.left+W-amtW, y, size:sz, font:fontBody, color:black});
|
||||
y -= lh + 2;
|
||||
const amtW = fontMono.widthOfTextAtSize(amtStr, sz);
|
||||
mono(amtStr, M.left + W - amtW, y);
|
||||
y -= lh + 4;
|
||||
|
||||
pg.drawText('Account', {x:M.left, 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;
|
||||
pg.drawText(ln.account || '–', {x:M.left, y, size:sz, font:fontBody, color:black});
|
||||
// Row 3 — Account | Program
|
||||
lbl('Account', M.left, y);
|
||||
lbl('Program', M.left + W * 0.5, y);
|
||||
y -= szXs + 4;
|
||||
|
||||
val(truncate(ln.account || '–', fontBody, sz, W * 0.5 - 8), M.left, y);
|
||||
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});
|
||||
val(truncate(progStr, fontBody, sz, W * 0.5 - 8), M.left + W * 0.5, y);
|
||||
y -= lh;
|
||||
} else {
|
||||
const lineBaseAmt = (() => { const a = parseFloat(ln.amount)||0, r = parseFloat(ln.fxRate)||1; return r>0?a/r:0; })();
|
||||
const lineBaseAmt = (() => { const a=parseFloat(ln.amount)||0, r=parseFloat(ln.fxRate)||1; return r>0?a/r:0; })();
|
||||
progs.forEach((pe, pi) => {
|
||||
if (pi > 0) needSpace(lh);
|
||||
const progStr = pe.program === 'Other' ? `Other: ${pe.programOther}` : (pe.program || '–');
|
||||
const pct = parseFloat(pe.percent) || 0;
|
||||
const progAmt = lineBaseAmt * pct / 100;
|
||||
const suffix = ` ${pct.toFixed(2)}% – ${baseCur} ${fmtAmt(progAmt)}`;
|
||||
pg.drawText(truncate(progStr, fontBody, sz, W * 0.45 - 8), {x:M.left+W*0.5, y, size:sz, font:fontBody, color:black});
|
||||
const sfxW = fontBody.widthOfTextAtSize(suffix, szSm);
|
||||
pg.drawText(suffix, {x:M.left+W-sfxW, y, size:szSm, font:fontBody, color:gray});
|
||||
const suffix = `${pct.toFixed(2)}% · ${baseCur} ${fmtAmt(progAmt)}`;
|
||||
val(truncate(progStr, fontBody, sz, W * 0.42 - 8), M.left + W * 0.5, y);
|
||||
const sfxW = fontMono.widthOfTextAtSize(suffix, szSm);
|
||||
pg.drawText(suffix, { x: M.left + W - sfxW, y, size: szSm, font: fontMono, color: clrMuted });
|
||||
y -= lh;
|
||||
});
|
||||
}
|
||||
|
||||
// Receipt page reference (filled in after receipt pages are built)
|
||||
if (ln.hasReceipt && ln.receipts.length > 0) {
|
||||
ln.receipts.forEach((r, ri) => {
|
||||
const key = `${ln.id}-${ri}`;
|
||||
const refX = M.left + W * 0.6;
|
||||
receiptRefs.push({ pageIdx: pages.length - 1, x: refX, y, key });
|
||||
receiptRefs.push({ pageIdx: pages.length - 1, x: M.left + W * 0.55, y, key });
|
||||
y -= lh;
|
||||
});
|
||||
} else if (!ln.hasReceipt) {
|
||||
needSpace(lh * 2);
|
||||
pg.drawText('Explanation for no receipt:', {x:M.left, y, size:szSm-1, font:fontBold, color:gray});
|
||||
y -= lh;
|
||||
lbl('Reason for no receipt', M.left, y);
|
||||
y -= szXs + 4;
|
||||
const explLines = wrapText(ln.noReceiptExplanation || '–', fontBody, sz, W);
|
||||
explLines.forEach(line => {
|
||||
needSpace(lh);
|
||||
pg.drawText(line, {x:M.left, y, size:sz, font:fontBody, color:black});
|
||||
y -= lh;
|
||||
});
|
||||
explLines.forEach(line => { needSpace(lh); val(line, M.left, y); y -= lh; });
|
||||
}
|
||||
|
||||
y -= 6;
|
||||
});
|
||||
|
||||
y -= 10;
|
||||
});
|
||||
|
||||
needSpace(lh * 2);
|
||||
pg.drawLine({start:{x:M.left,y}, end:{x:M.left+W,y}, thickness:3, color:accent});
|
||||
// Grand total
|
||||
needSpace(lh * 3 + 6);
|
||||
y -= 6;
|
||||
pg.drawLine({ start:{x:M.left, y}, end:{x:M.left+W, y}, thickness:1.5, color:clrBorderStrong });
|
||||
y -= lh;
|
||||
const gtStr = `Total reimbursement claim: ${baseCur} ${fmtAmt(state._grandTotal)}`;
|
||||
const gtW = fontBold.widthOfTextAtSize(gtStr, sz + 2);
|
||||
pg.drawText(gtStr, {x: M.left + W - gtW, y, size: sz+2, font: fontBold, color: accent});
|
||||
|
||||
// ---- RECEIPT PAGES ----
|
||||
pg.drawText('Total reimbursement claim', { x: M.left, y, size: sz, font: fontBold, color: clrTextSoft });
|
||||
const gtStr = `${baseCur} ${fmtAmt(state._grandTotal)}`;
|
||||
const gtW = fontBold.widthOfTextAtSize(gtStr, szLg);
|
||||
pg.drawText(gtStr, { x: M.left + W - gtW, y: y - (szLg - sz) / 2, size: szLg, font: fontBold, color: clrAccent });
|
||||
|
||||
// ── Receipt pages ─────────────────────────────────────────────────────────
|
||||
const formPageCount = pages.length;
|
||||
const receiptPageMap = {};
|
||||
|
||||
|
|
@ -1416,7 +1582,7 @@ async function generatePDF() {
|
|||
const ep = doc.addPage([pageW, pageH]);
|
||||
pages.push(ep);
|
||||
ep.drawText(`Could not embed: ${r.name}`, {x:M.left, y:pageH-M.top, size:sz, font:fontBody, color:rgb(0.8,0,0)});
|
||||
ep.drawText(String(e.message || e), {x:M.left, y:pageH-M.top-lh, size:szSm, font:fontBody, color:gray});
|
||||
ep.drawText(String(e.message || e), {x:M.left, y:pageH-M.top-lh, size:szSm, font:fontBody, color:clrMuted});
|
||||
}
|
||||
} else {
|
||||
const rp = doc.addPage([pageW, pageH]);
|
||||
|
|
@ -1425,13 +1591,10 @@ async function generatePDF() {
|
|||
let img;
|
||||
if (r.type === 'image/png') img = await doc.embedPng(r.data);
|
||||
else img = await doc.embedJpg(r.data);
|
||||
const maxW2 = pageW - M.left - M.right;
|
||||
const maxH2 = pageH - M.top - M.bottom;
|
||||
const maxW2 = pageW - M.left - M.right, maxH2 = pageH - M.top - M.bottom;
|
||||
const sc = Math.min(maxW2 / img.width, maxH2 / img.height, 1);
|
||||
const iw = img.width * sc, ih = img.height * sc;
|
||||
const ix = M.left + (maxW2 - iw) / 2;
|
||||
const iy = M.bottom + (maxH2 - ih) / 2;
|
||||
rp.drawImage(img, {x:ix, y:iy, width:iw, height:ih});
|
||||
rp.drawImage(img, { x: M.left + (maxW2-iw)/2, y: M.bottom + (maxH2-ih)/2, width:iw, height:ih });
|
||||
} catch (e) {
|
||||
rp.drawText(`Could not embed: ${r.name}`, {x:M.left, y:pageH-M.top, size:sz, font:fontBody, color:rgb(0.8,0,0)});
|
||||
}
|
||||
|
|
@ -1441,31 +1604,33 @@ async function generatePDF() {
|
|||
}
|
||||
}
|
||||
|
||||
// ── Back-fill receipt page references ─────────────────────────────────────
|
||||
receiptRefs.forEach(ref => {
|
||||
const pageNum = receiptPageMap[ref.key];
|
||||
if (pageNum != null) {
|
||||
const pg2 = pages[ref.pageIdx];
|
||||
pg2.drawText(`See page ${pageNum} for receipt`, {x:ref.x, y:ref.y, size:szSm, font:fontBody, color:gray});
|
||||
}
|
||||
if (pageNum != null)
|
||||
pages[ref.pageIdx].drawText(`See page ${pageNum} for receipt`,
|
||||
{ x:ref.x, y:ref.y, size:szSm, font:fontBody, color:clrMuted });
|
||||
});
|
||||
|
||||
// ── Footers on every page ─────────────────────────────────────────────────
|
||||
const totalPages = pages.length;
|
||||
const nowTs = new Date();
|
||||
const printed = `${nowTs.getFullYear()}-${String(nowTs.getMonth()+1).padStart(2,'0')}-${String(nowTs.getDate()).padStart(2,'0')} ${String(nowTs.getHours()).padStart(2,'0')}:${String(nowTs.getMinutes()).padStart(2,'0')}`;
|
||||
|
||||
pages.forEach((p, i) => {
|
||||
const fy = M.bottom - 30;
|
||||
p.drawLine({start:{x:M.left, y:fy+18}, end:{x:M.left+W, y:fy+18}, thickness:0.5, color:lineCol});
|
||||
p.drawText('Reimbursement form', {x:M.left, y:fy, size:szSm-1, font:fontBody, color:gray});
|
||||
p.drawText(state.staff, {x:M.left, y:fy-lh+2, size:szSm-1, font:fontBody, color:gray});
|
||||
p.drawLine({ start:{x:M.left, y:fy+18}, end:{x:M.left+W, y:fy+18}, thickness:0.5, color:clrBorder });
|
||||
p.drawText('Reimbursement form', { x:M.left, y:fy, size:szXs, font:fontBody, color:clrMuted });
|
||||
p.drawText(state.staff, { x:M.left, y:fy-lh+2, size:szXs, font:fontBody, color:clrMuted });
|
||||
const pgStr = `Page ${i+1}/${totalPages}`;
|
||||
const pgW2 = fontBody.widthOfTextAtSize(pgStr, szSm-1);
|
||||
p.drawText(pgStr, {x:M.left+W-pgW2, y:fy, size:szSm-1, font:fontBody, color:gray});
|
||||
const pgW2 = fontBody.widthOfTextAtSize(pgStr, szXs);
|
||||
p.drawText(pgStr, { x:M.left+W-pgW2, y:fy, size:szXs, font:fontBody, color:clrMuted });
|
||||
const prStr = `Printed: ${printed}`;
|
||||
const prW = fontBody.widthOfTextAtSize(prStr, szSm-1);
|
||||
p.drawText(prStr, {x:M.left+W-prW, y:fy-lh+2, size:szSm-1, font:fontBody, color:gray});
|
||||
const prW = fontBody.widthOfTextAtSize(prStr, szXs);
|
||||
p.drawText(prStr, { x:M.left+W-prW, y:fy-lh+2, size:szXs, font:fontBody, color:clrMuted });
|
||||
});
|
||||
|
||||
// ── Save and download ──────────────────────────────────────────────────────
|
||||
const pdfBytes = await doc.save();
|
||||
const blob = new Blob([pdfBytes], {type:'application/pdf'});
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
|
|
|||
Loading…
Reference in a new issue