Compare commits

..

No commits in common. "df8816dfb4bcb6f310db89cdade54dc7f7ee91ac" and "b1efad2ab497e827d89e8aebbdf3b2fbb8ae6eea" have entirely different histories.

8 changed files with 111 additions and 168 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 474 B

View file

Before

Width:  |  Height:  |  Size: 893 B

After

Width:  |  Height:  |  Size: 893 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

View file

@ -1,35 +0,0 @@
{
"name": "Reimburse",
"short_name": "reimburse",
"theme_color": "#2f6fed",
"background_color": "#2f6fed",
"display": "standalone",
"icons": [
{
"src": "icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "apple-touch-icon.png",
"sizes": "180x180",
"type": "image/png"
},
{
"src": "favicon-32x32.png",
"sizes": "32x32",
"type": "image/png"
},
{
"src": "favicon-16x16.png",
"sizes": "16x16",
"type": "image/png"
}
]
}

View file

@ -6,12 +6,8 @@
<meta name="format-detection" content="telephone=no"> <meta name="format-detection" content="telephone=no">
<title>Reimbursement</title> <title>Reimbursement</title>
<link rel="icon" href="assets/favicon.svg" type="image/svg+xml"> <link rel="icon" href="assets/favicon.svg" type="image/svg+xml">
<link rel="icon" href="assets/favicon-32x32.png" sizes="32x32" type="image/png"> <link rel="alternate icon" href="assets/favicon-32.png" sizes="32x32">
<link rel="icon" href="assets/favicon-16x16.png" sizes="16x16" type="image/png">
<link rel="shortcut icon" href="assets/favicon.ico">
<link rel="apple-touch-icon" href="assets/apple-touch-icon.png"> <link rel="apple-touch-icon" href="assets/apple-touch-icon.png">
<link rel="manifest" href="assets/site.webmanifest">
<meta name="theme-color" content="#2f6fed">
<script src="https://unpkg.com/pdf-lib@1.17.1/dist/pdf-lib.min.js"></script> <script src="https://unpkg.com/pdf-lib@1.17.1/dist/pdf-lib.min.js"></script>
<script src="https://unpkg.com/js-yaml@4.1.0/dist/js-yaml.min.js"></script> <script src="https://unpkg.com/js-yaml@4.1.0/dist/js-yaml.min.js"></script>
<script> <script>
@ -112,10 +108,10 @@ body{
.kb-mono{font-family:var(--font-mono);font-variant-numeric:tabular-nums;} .kb-mono{font-family:var(--font-mono);font-variant-numeric:tabular-nums;}
/* ── Page shell ───────────────────────────────────────────────────────────── */ /* ── Page shell ───────────────────────────────────────────────────────────── */
.kb-wrap{max-width:980px;margin:0 auto;padding:24px 20px 56px;} .kb-wrap{max-width:960px;margin:0 auto;padding:24px 20px 56px;}
/* ── Toolbar ──────────────────────────────────────────────────────────────── */ /* ── Toolbar ──────────────────────────────────────────────────────────────── */
.kb-toolbar{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:18px;} .kb-toolbar{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:16px;}
.kb-toolbar .spacer{flex:1;} .kb-toolbar .spacer{flex:1;}
.kb-seg{ .kb-seg{
display:inline-flex;align-items:center;gap:2px; display:inline-flex;align-items:center;gap:2px;
@ -125,13 +121,12 @@ body{
.kb-seg button{ .kb-seg button{
font:600 var(--fs-small)/1 var(--font-sans);color:var(--text-muted); font:600 var(--fs-small)/1 var(--font-sans);color:var(--text-muted);
background:transparent;border:0;white-space:nowrap; background:transparent;border:0;white-space:nowrap;
padding:5px 10px;border-radius:4px;cursor:pointer; padding:6px 11px;border-radius:4px;cursor:pointer;
} }
.kb-seg button.is-active{background:var(--accent-soft);color:var(--accent);} .kb-seg button.is-active{background:var(--accent-soft);color:var(--accent);}
.kb-seg button:hover:not(.is-active):not(:disabled){color:var(--text);} .kb-seg button:hover:not(.is-active){color:var(--text);}
.kb-seg button:disabled{opacity:.4;cursor:not-allowed;}
.kb-iconbtn{ .kb-iconbtn{
display:inline-grid;place-items:center;width:32px;height:32px; display:inline-grid;place-items:center;width:34px;height:34px;
background:var(--surface);border:1px solid var(--border); background:var(--surface);border:1px solid var(--border);
border-radius:var(--radius-sm);color:var(--text-muted);cursor:pointer; border-radius:var(--radius-sm);color:var(--text-muted);cursor:pointer;
} }
@ -146,7 +141,8 @@ body{
.kb-brand{display:flex;align-items:center;gap:14px;min-width:0;} .kb-brand{display:flex;align-items:center;gap:14px;min-width:0;}
.kb-brand .logo{ .kb-brand .logo{
height:46px;width:46px;flex:0 0 46px;border-radius:10px; height:46px;width:46px;flex:0 0 46px;border-radius:10px;
display:grid;place-items:center;overflow:hidden; display:grid;place-items:center;background:var(--accent-soft);
color:var(--accent);font-weight:800;font-size:18px;overflow:hidden;
} }
.kb-brand .logo img{width:100%;height:100%;object-fit:contain;} .kb-brand .logo img{width:100%;height:100%;object-fit:contain;}
.kb-brand .org{font-size:17px;font-weight:700;color:var(--text);letter-spacing:-0.01em;} .kb-brand .org{font-size:17px;font-weight:700;color:var(--text);letter-spacing:-0.01em;}
@ -339,6 +335,18 @@ body{
/* ── App wordmark (toolbar left) ──────────────────────────────────────────── */ /* ── App wordmark (toolbar left) ──────────────────────────────────────────── */
.kb-doctitle h1{display:inline-flex;align-items:center;gap:9px;} .kb-doctitle h1{display:inline-flex;align-items:center;gap:9px;}
.kb-doctitle h1 svg{flex-shrink:0;} .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 ──────────────────────────────────────────────────────────────── */ /* ── Loading ──────────────────────────────────────────────────────────────── */
.kb-loading{text-align:center;padding:80px;color:var(--text-muted);} .kb-loading{text-align:center;padding:80px;color:var(--text-muted);}
@ -346,7 +354,6 @@ body{
/* ── Responsive ───────────────────────────────────────────────────────────── */ /* ── Responsive ───────────────────────────────────────────────────────────── */
@media (max-width:680px){ @media (max-width:680px){
.kb-grid.cols-2,.kb-grid.cols-3,.kb-grid.cols-4,.kb-grid.claim-grid{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-grid.claim-grid .kb-field{justify-content:flex-end;}
.kb-header{flex-direction:column;gap:14px;} .kb-header{flex-direction:column;gap:14px;}
.kb-doctitle{text-align:left;} .kb-doctitle{text-align:left;}
.kb-line-section.row1,.kb-line-section.row2,.kb-line-section.row3{grid-template-columns:1fr;} .kb-line-section.row1,.kb-line-section.row2,.kb-line-section.row3{grid-template-columns:1fr;}
@ -684,15 +691,6 @@ function makeSelect(options, value, onChange, placeholder) {
} }
// ========== FORM RENDERING ========== // ========== FORM RENDERING ==========
function appIconSVG() {
const s = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
s.setAttribute('viewBox', '0 0 48 48');
s.setAttribute('aria-hidden', 'true');
s.style.cssText = 'width:100%;height:100%;display:block;';
s.innerHTML = `<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"/>`;
return s;
}
function render() { function render() {
const app = $('#app'); const app = $('#app');
app.innerHTML = ''; app.innerHTML = '';
@ -707,7 +705,7 @@ function render() {
// Language selector (only when CFG.languages has >1 entry) — goes first // Language selector (only when CFG.languages has >1 entry) — goes first
if (Array.isArray(CFG.languages) && CFG.languages.length > 1) { if (Array.isArray(CFG.languages) && CFG.languages.length > 1) {
const langSel = el('select', {className:'kb-select', 'aria-label':'Language', style:'width:auto;padding:5px 28px 5px 8px;font-size:var(--fs-small)'}); const langSel = el('select', {'aria-label':'Language'});
CFG.languages.forEach(lang => { CFG.languages.forEach(lang => {
const code = typeof lang === 'object' ? lang.code : lang; const code = typeof lang === 'object' ? lang.code : lang;
const name = typeof lang === 'object' ? lang.name : lang; const name = typeof lang === 'object' ? lang.name : lang;
@ -718,24 +716,20 @@ function render() {
toolbar.appendChild(el('div', {className:'spacer'})); toolbar.appendChild(el('div', {className:'spacer'}));
// Font size — A / A+ with a live % label // Font size
const scales = [0.5,0.6,0.7,0.8,0.9,1,1.1,1.2,1.3,1.4,1.5];
let scaleIdx = 5; // default: 100%
const szLabel = el('span', {style:'font-size:12px;color:var(--text-muted);font-family:var(--font-mono)'}, '100%');
const sizeSeg = el('div', {className:'kb-seg', role:'group', 'aria-label':'Text size'}); const sizeSeg = el('div', {className:'kb-seg', role:'group', 'aria-label':'Text size'});
const szDown = el('button', {type:'button', 'aria-label':'Smaller text'}, 'A'); const sizeOpts = [{lbl:'A', scale:0.9}, {lbl:'A', scale:1}, {lbl:'A+', scale:1.12}];
const szUp = el('button', {type:'button', 'aria-label':'Larger text'}, 'A+'); let activeSizeIdx = 1;
function applyScale() { sizeOpts.forEach((s, i) => {
szDown.disabled = scaleIdx === 0; const btn = el('button', {type:'button', className: i === activeSizeIdx ? 'is-active' : ''}, s.lbl);
szUp.disabled = scaleIdx === scales.length - 1; btn.addEventListener('click', () => {
document.documentElement.style.setProperty('--font-scale', scales[scaleIdx]); $$('button', sizeSeg).forEach(b => b.classList.remove('is-active'));
szLabel.textContent = Math.round(scales[scaleIdx] * 100) + '%'; btn.classList.add('is-active');
} document.documentElement.style.setProperty('--font-scale', s.scale);
szDown.addEventListener('click', () => { if (scaleIdx > 0) { scaleIdx--; applyScale(); } }); });
szUp.addEventListener('click', () => { if (scaleIdx < scales.length-1) { scaleIdx++; applyScale(); } }); sizeSeg.appendChild(btn);
sizeSeg.append(szDown, szUp); });
toolbar.appendChild(sizeSeg); toolbar.appendChild(sizeSeg);
toolbar.appendChild(szLabel);
// Theme toggle (single icon button: moon = light mode, sun = dark mode) // 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 themeBtn = el('button', {className:'kb-iconbtn', type:'button', 'aria-label':'Toggle theme'});
@ -768,14 +762,16 @@ function render() {
img.src = 'assets/logo.png'; img.src = 'assets/logo.png';
img.onerror = function() { img.onerror = function() {
this.src = 'assets/logo.jpg'; this.src = 'assets/logo.jpg';
this.onerror = function() { this.replaceWith(appIconSVG()); }; this.onerror = function() {
this.replaceWith(document.createTextNode((CFG.organization || 'ORG').slice(0,2).toUpperCase()));
};
}; };
if (CFG['logo-maxwidth']) img.style.maxWidth = CFG['logo-maxwidth'] * 28.3465 + 'px'; if (CFG['logo-maxwidth']) img.style.maxWidth = CFG['logo-maxwidth'] * 28.3465 + 'px';
logoBox.appendChild(img); logoBox.appendChild(img);
} else { } else {
logoBox.appendChild(appIconSVG()); logoBox.textContent = (CFG.organization || 'ORG').slice(0,2).toUpperCase();
} }
const orgSpan = el('span', {className:'org'}, CFG.organization || 'kbenestad.reimburse'); const orgSpan = el('span', {className:'org'}, CFG.organization || '');
orgSpan.appendChild(el('small', null, 'Expense reimbursement')); orgSpan.appendChild(el('small', null, 'Expense reimbursement'));
brand.append(logoBox, orgSpan); brand.append(logoBox, orgSpan);
@ -825,15 +821,15 @@ function render() {
}); });
const claimCard = el('div', {className:'kb-card'}); const claimCard = el('div', {className:'kb-card'});
claimCard.appendChild(el('h2', {className:'kb-card__title'}, 'Claimant'));
const claimGrid = el('div', {className:'kb-grid claim-grid', style:{gridTemplateColumns:'2fr 0.85fr 0.85fr 0.5fr', alignItems:'end'}}); 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('Staff', staffInput));
claimGrid.appendChild(kbField('Period from', fromInput)); claimGrid.appendChild(kbField('Period from', fromInput));
claimGrid.appendChild(kbField('Period to', toInput)); claimGrid.appendChild(kbField('Period to', toInput));
claimGrid.appendChild(kbField('Base currency', baseCurDD)); claimGrid.appendChild(kbField('Base currency', baseCurDD));
claimCard.appendChild(claimGrid); claimCard.appendChild(claimGrid);
const newFormBtn = el('button', {type:'button', className:'kb-btn kb-btn--soft', style:{marginTop:'12px'}, onClick: onNewForm}, 'New form'); const newFormBtn = el('button', {type:'button', className:'kb-btn kb-btn--ghost', style:{marginTop:'12px'}, onClick: onNewForm}, 'New form');
claimCard.appendChild(newFormBtn); claimCard.appendChild(newFormBtn);
wrap.appendChild(claimCard); wrap.appendChild(claimCard);
@ -866,35 +862,12 @@ function render() {
totals.appendChild(grandRow); totals.appendChild(grandRow);
actCard.appendChild(totals); actCard.appendChild(totals);
const actionRow = el('div', {style:{display:'flex', gap:'10px', marginTop:'8px'}}); const actionRow = el('div', {style:{display:'flex', gap:'12px', marginTop:'16px', flexWrap:'wrap'}});
const actionFeedback = el('div', {style:{marginTop:'10px'}}); const saveBtn = el('button', {className:'kb-btn kb-btn--soft', onClick: onSave}, 'Save form');
const genBtn = el('button', {className:'kb-btn kb-btn--primary kb-btn--lg', id:'gen-btn', style:{flex:'1'}, onClick: onGenerate});
const saveBtn = el('button', {className:'kb-btn kb-btn--ghost kb-btn--lg', style:{flex:'2'}}, 'Save'); genBtn.innerHTML = `<svg viewBox="0 0 16 16" fill="currentColor" width="16" height="16"><path d="M8 1a1 1 0 0 1 1 1v6.6l2.3-2.3a1 1 0 0 1 1.4 1.4l-4 4a1 1 0 0 1-1.4 0l-4-4a1 1 0 0 1 1.4-1.4L7 8.6V2a1 1 0 0 1 1-1zM3 13h10a1 1 0 1 1 0 2H3a1 1 0 1 1 0-2z"/></svg>Generate PDF`;
saveBtn.addEventListener('click', () => saveState()); actionRow.append(saveBtn, genBtn);
actCard.appendChild(actionRow);
const validateBtn = el('button', {className:'kb-btn kb-btn--ghost kb-btn--lg', style:{flex:'2'}}, 'Validate');
validateBtn.addEventListener('click', () => {
actionFeedback.innerHTML = '';
const errs = validate();
if (errs.length) {
const note = el('div', {className:'kb-note kb-note--error'});
note.innerHTML = `<svg viewBox="0 0 16 16" fill="currentColor"><path d="M8.982 1.566a1.13 1.13 0 0 0-1.96 0L.165 13.233c-.457.778.091 1.767.98 1.767h13.713c.889 0 1.438-.99.98-1.767L8.982 1.566zM8 5c.535 0 .954.462.9.995l-.35 3.507a.552.552 0 0 1-1.1 0L7.1 5.995A.905.905 0 0 1 8 5zm.002 6a1 1 0 1 1 0 2 1 1 0 0 1 0-2z"/></svg>`;
const txt = el('div');
txt.innerHTML = '<strong>Please fix the following:</strong><br>' + errs.join('<br>');
note.appendChild(txt);
actionFeedback.appendChild(note);
} else {
const note = el('div', {className:'kb-note kb-note--success'});
note.innerHTML = `<svg viewBox="0 0 16 16" fill="currentColor"><path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zm-3.97-3.03a.75.75 0 0 0-1.08.022L7.477 9.417 5.384 7.323a.75.75 0 0 0-1.06 1.061L6.97 11.03a.75.75 0 0 0 1.079-.02l3.992-4.99a.75.75 0 0 0-.01-1.05z"/></svg><div><strong>All good.</strong> The form is ready to download.</div>`;
actionFeedback.appendChild(note);
}
});
const genBtn = el('button', {className:'kb-btn kb-btn--primary kb-btn--lg', id:'gen-btn', style:{flex:'6'}, onClick: onGenerate});
genBtn.innerHTML = `<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="16" height="16"><path d="M8 2v9m0 0 4-4M8 11 4 7"/><line x1="2" y1="14" x2="14" y2="14"/></svg>Download reimbursement form`;
actionRow.append(saveBtn, validateBtn, genBtn);
actCard.append(actionRow, actionFeedback);
wrap.appendChild(actCard); wrap.appendChild(actCard);
app.appendChild(wrap); app.appendChild(wrap);
@ -1381,18 +1354,17 @@ async function generatePDF() {
// Continuation header: light strip with staff + period // Continuation header: light strip with staff + period
function drawContHeader() { function drawContHeader() {
const cPad = 7; const stripH = szXs + lh + 14;
const stripH = 2 * cPad + szXs + 5 + sz;
pg.drawRectangle({ x: 0, y: y - stripH, width: pageW, height: stripH, color: clrSurface2 }); 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}, 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 }); pg.drawLine({ start:{x:0, y:y-stripH}, end:{x:pageW, y:y-stripH}, thickness:0.5, color:clrBorder });
const labY = y - Math.round(cPad + szXs * 0.72); const labY = y - 7;
const valY = labY - szXs - 5; const valY = labY - szXs - 5;
lbl('Staff', M.left, labY); lbl('Staff', M.left, labY);
lbl('Period', M.left + W * 0.45, labY); lbl('Period', M.left + W * 0.45, labY);
pg.drawText(state.staff, { x:M.left, y:valY, size:sz, font:fontBold, color:clrText }); pg.drawText(state.staff, { x:M.left, y:valY, size:sz, font:fontBold, color:clrText });
val(`${state.periodFrom} to ${state.periodTo}`, M.left + W * 0.45, valY); mono(`${state.periodFrom} to ${state.periodTo}`, M.left + W * 0.45, valY);
y -= stripH + 12; y -= stripH + 12;
} }
@ -1400,38 +1372,47 @@ async function generatePDF() {
// ── Page 1 ──────────────────────────────────────────────────────────────── // ── Page 1 ────────────────────────────────────────────────────────────────
addPage(true); addPage(true);
// Header: org name left | REIMBURSEMENT right (timesheet style) // Header: brand block left | doc title right
const hdrTopY = y; const hdrTopY = y;
const nowD = new Date(); const boxSize = 44;
const MONTHS = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
const hdrNameSize = szLg; // org name and title same size
const hdrNameY = hdrTopY - Math.round(hdrNameSize * 0.28); // cap top near hdrTopY
if (logoImage) { if (logoImage) {
const maxWLogo = (CFG['logo-maxwidth'] || 4) * 28.3465; const maxWLogo = (CFG['logo-maxwidth'] || 4) * 28.3465;
const scale = Math.min(maxWLogo / logoImage.width, hdrNameSize * 1.6 / logoImage.height, 1); const scale = Math.min(maxWLogo / logoImage.width, boxSize / logoImage.height, 1);
const lw = logoImage.width * scale, lhImg = logoImage.height * scale; const lw = logoImage.width * scale, lhImg = logoImage.height * scale;
pg.drawImage(logoImage, { x: M.left, y: hdrTopY - lhImg, width: lw, height: lhImg }); pg.drawImage(logoImage, { x: M.left, y: hdrTopY - lhImg, width: lw, height: lhImg });
} else { } else {
pg.drawText(CFG.organization || '', { x: M.left, y: hdrNameY, size: hdrNameSize, font: fontBold, color: clrText }); // 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 });
} }
const hdrSubtY = hdrNameY - hdrNameSize - 5;
pg.drawText('Expense reimbursement', { x: M.left, y: hdrSubtY, size: szSm, font: fontBody, color: clrMuted });
const titleStr = 'REIMBURSEMENT'; // Org name + subtitle
const titleW = fontBold.widthOfTextAtSize(titleStr, hdrNameSize); const orgX = M.left + boxSize + 11;
pg.drawText(titleStr, { x: M.left + W - titleW, y: hdrNameY, size: hdrNameSize, font: fontBold, color: clrText }); 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 claimDate = `Claim · ${nowD.getDate()} ${MONTHS[nowD.getMonth()]} ${nowD.getFullYear()}`;
const claimDateW = fontBody.widthOfTextAtSize(claimDate, szSm); const claimDateW = fontMono.widthOfTextAtSize(claimDate, szSm);
pg.drawText(claimDate, { x: M.left + W - claimDateW, y: hdrSubtY, size: szSm, font: fontBody, color: clrMuted }); pg.drawText(claimDate, { x: M.left + W - claimDateW, y: hdrTopY - 15 - szLg - 4, size: szSm, font: fontMono, color: clrMuted });
y = hdrSubtY - szSm - 12; y = hdrTopY - boxSize - 10;
// Thick accent divider below header // Hairline below header
pg.drawLine({ start:{x:M.left, y}, end:{x:M.left+W, y}, thickness:2.5, color:clrAccent }); pg.drawLine({ start:{x:M.left, y}, end:{x:M.left+W, y}, thickness:0.5, color:clrBorder });
y -= 16; y -= 12;
// Intro text // Intro text
if (CFG.intro) { if (CFG.intro) {
@ -1440,14 +1421,13 @@ async function generatePDF() {
y -= 6; y -= 6;
} }
// Staff / Period / Currency info block — equal visual padding top and bottom // Staff / Period / Currency info block
const infoPad = 7; const infoH = szXs + lh + 14;
const infoH = 2 * infoPad + szXs + 5 + sz;
pg.drawRectangle({ x: M.left - 8, y: y - infoH, width: W + 16, height: infoH, color: clrSurface2 }); 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}, 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.drawLine({ start:{x:M.left-8, y:y-infoH}, end:{x:M.left+W+8, y:y-infoH}, thickness:0.5, color:clrBorder });
const iLabY = y - Math.round(infoPad + szXs * 0.72); const iLabY = y - 7;
const iValY = iLabY - szXs - 5; const iValY = iLabY - szXs - 5;
const iC2 = W * 0.45, iC3 = W * 0.78; const iC2 = W * 0.45, iC3 = W * 0.78;
@ -1456,35 +1436,34 @@ async function generatePDF() {
lbl('Currency', M.left + iC3, iLabY); lbl('Currency', M.left + iC3, iLabY);
pg.drawText(state.staff, { x: M.left, y: iValY, size: sz, font: fontBold, color: clrText }); pg.drawText(state.staff, { x: M.left, y: iValY, size: sz, font: fontBold, color: clrText });
val(`${state.periodFrom} to ${state.periodTo}`, M.left + iC2, iValY); 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 }); pg.drawText(baseCur, { x: M.left + iC3, y: iValY, size: sz, font: fontBold, color: clrAccent });
y -= infoH + 20; y -= infoH + 14;
// ── Items ───────────────────────────────────────────────────────────────── // ── Items ─────────────────────────────────────────────────────────────────
state.items.forEach(item => { state.items.forEach(item => {
needSpace(lh * 7); needSpace(lh * 7);
// Section header strip — equal visual padding, accent left bar // Section header: accent stripe + item name left, subtotal right
const secH = sz + 16; accentStripe(y);
const secTextY = Math.round(y - secH / 2 + sz * 0.22); pg.drawText(item.name || '(no name)', { x: M.left + 8, y, size: sz, font: fontBold, color: clrTextSoft });
pg.drawRectangle({ x: M.left, y: y - secH, width: W, height: secH, color: clrSurface2 });
pg.drawLine({ start:{x:M.left, y}, end:{x:M.left+W, y}, thickness:0.5, color:clrBorder });
pg.drawLine({ start:{x:M.left, y:y-secH}, end:{x:M.left+W, y:y-secH}, thickness:0.5, color:clrBorder });
pg.drawRectangle({ x: M.left, y: y - secH, width: 3, height: secH, color: clrAccent });
pg.drawText(item.name || '(no name)', { x: M.left + 10, y: secTextY, size: sz, font: fontBold, color: clrTextSoft });
const subStr = `${baseCur} ${fmtAmt(item._subtotal)}`; const subStr = `${baseCur} ${fmtAmt(item._subtotal)}`;
const subW = fontBold.widthOfTextAtSize(subStr, sz); const subW = fontMono.widthOfTextAtSize(subStr, sz);
pg.drawText(subStr, { x: M.left + W - subW, y: secTextY, size: sz, font: fontBold, color: clrAccent }); pg.drawText(subStr, { x: M.left + W - subW, y, size: sz, font: fontMono, color: clrAccent });
y -= secH + 14; 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 // Lines
item.lines.forEach((ln, li) => { item.lines.forEach((ln, li) => {
needSpace(lh * 9); needSpace(lh * 9);
if (li > 0 && !justBroke) { if (li > 0 && !justBroke) {
y -= 8; y -= 4;
pg.drawLine({ start:{x:M.left, y}, end:{x:M.left+W, y}, thickness:0.3, color:clrBorder }); pg.drawLine({ start:{x:M.left, y}, end:{x:M.left+W, y}, thickness:0.3, color:clrBorder });
y -= 12; y -= 8;
} }
const c1=0, c2=W*0.22, c3=W*0.64; const c1=0, c2=W*0.22, c3=W*0.64;
@ -1496,17 +1475,17 @@ async function generatePDF() {
const fxLblStr = 'FX RATE'; const fxLblStr = 'FX RATE';
pg.drawText(fxLblStr, { x: M.left + W - fontBold.widthOfTextAtSize(fxLblStr, szXs), y, pg.drawText(fxLblStr, { x: M.left + W - fontBold.widthOfTextAtSize(fxLblStr, szXs), y,
size: szXs, font: fontBold, color: clrMuted }); size: szXs, font: fontBold, color: clrMuted });
y -= szXs + 6; y -= szXs + 4;
const dateInPeriod = isDateInPeriod(ln.date); const dateInPeriod = isDateInPeriod(ln.date);
val((ln.date || '') + (dateInPeriod ? '' : ' (!)'), M.left + c1, y, mono((ln.date || '') + (dateInPeriod ? '' : ' (!)'), M.left + c1, y,
{ color: dateInPeriod ? clrText : clrWarn }); { color: dateInPeriod ? clrText : clrWarn });
val(truncate(ln.vendor, fontBody, sz, (c3 - c2) - 8), M.left + c2, y); val(truncate(ln.vendor, fontBody, sz, (c3 - c2) - 8), M.left + c2, y);
val(ln.currency || '', M.left + c3, y); val(ln.currency || '', M.left + c3, y);
const fxStr = ln.currency === baseCur ? '' : parseFloat(ln.fxRate).toFixed(5); const fxStr = ln.currency === baseCur ? '' : parseFloat(ln.fxRate).toFixed(5);
const fxW = fontBody.widthOfTextAtSize(fxStr, sz); const fxW = fontMono.widthOfTextAtSize(fxStr, sz);
val(fxStr, M.left + W - fxW, y); mono(fxStr, M.left + W - fxW, y);
y -= lh + 10; y -= lh + 4;
// Row 2 — Description | Receipt | Amount // Row 2 — Description | Receipt | Amount
lbl('Description', M.left, y); lbl('Description', M.left, y);
@ -1514,19 +1493,19 @@ async function generatePDF() {
const amtLblStr = 'AMOUNT'; const amtLblStr = 'AMOUNT';
pg.drawText(amtLblStr, { x: M.left + W - fontBold.widthOfTextAtSize(amtLblStr, szXs), y, pg.drawText(amtLblStr, { x: M.left + W - fontBold.widthOfTextAtSize(amtLblStr, szXs), y,
size: szXs, font: fontBold, color: clrMuted }); size: szXs, font: fontBold, color: clrMuted });
y -= szXs + 6; y -= szXs + 4;
val(truncate(ln.description, fontBody, sz, c3 - 8), M.left, y); val(truncate(ln.description, fontBody, sz, c3 - 8), M.left, y);
val(ln.hasReceipt ? 'Yes' : 'No', M.left + c3, y); val(ln.hasReceipt ? 'Yes' : 'No', M.left + c3, y);
const amtStr = `${ln.currency} ${fmtAmt(ln.amount)}`; const amtStr = `${ln.currency} ${fmtAmt(ln.amount)}`;
const amtW = fontBody.widthOfTextAtSize(amtStr, sz); const amtW = fontMono.widthOfTextAtSize(amtStr, sz);
val(amtStr, M.left + W - amtW, y); mono(amtStr, M.left + W - amtW, y);
y -= lh + 10; y -= lh + 4;
// Row 3 — Account | Program // Row 3 — Account | Program
lbl('Account', M.left, y); lbl('Account', M.left, y);
lbl('Program', M.left + W * 0.5, y); lbl('Program', M.left + W * 0.5, y);
y -= szXs + 6; y -= szXs + 4;
val(truncate(ln.account || '', fontBody, sz, W * 0.5 - 8), M.left, y); val(truncate(ln.account || '', fontBody, sz, W * 0.5 - 8), M.left, y);
const progs = ln.programs || []; const progs = ln.programs || [];
@ -1534,7 +1513,7 @@ async function generatePDF() {
const pe = progs[0] || {}; const pe = progs[0] || {};
const progStr = pe.program === 'Other' ? `Other: ${pe.programOther}` : (pe.program || ''); const progStr = pe.program === 'Other' ? `Other: ${pe.programOther}` : (pe.program || '');
val(truncate(progStr, fontBody, sz, W * 0.5 - 8), M.left + W * 0.5, y); val(truncate(progStr, fontBody, sz, W * 0.5 - 8), M.left + W * 0.5, y);
y -= lh + 4; y -= lh;
} else { } 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) => { progs.forEach((pe, pi) => {
@ -1544,8 +1523,8 @@ async function generatePDF() {
const progAmt = lineBaseAmt * pct / 100; const progAmt = lineBaseAmt * pct / 100;
const suffix = `${pct.toFixed(2)}% · ${baseCur} ${fmtAmt(progAmt)}`; const suffix = `${pct.toFixed(2)}% · ${baseCur} ${fmtAmt(progAmt)}`;
val(truncate(progStr, fontBody, sz, W * 0.42 - 8), M.left + W * 0.5, y); val(truncate(progStr, fontBody, sz, W * 0.42 - 8), M.left + W * 0.5, y);
const sfxW = fontBody.widthOfTextAtSize(suffix, szSm); const sfxW = fontMono.widthOfTextAtSize(suffix, szSm);
pg.drawText(suffix, { x: M.left + W - sfxW, y, size: szSm, font: fontBody, color: clrMuted }); pg.drawText(suffix, { x: M.left + W - sfxW, y, size: szSm, font: fontMono, color: clrMuted });
y -= lh; y -= lh;
}); });
} }
@ -1565,7 +1544,7 @@ async function generatePDF() {
explLines.forEach(line => { needSpace(lh); val(line, M.left, y); y -= lh; }); explLines.forEach(line => { needSpace(lh); val(line, M.left, y); y -= lh; });
} }
y -= 2; y -= 6;
}); });
y -= 10; y -= 10;
@ -1573,7 +1552,7 @@ async function generatePDF() {
// Grand total // Grand total
needSpace(lh * 3 + 6); needSpace(lh * 3 + 6);
y -= 4; y -= 6;
pg.drawLine({ start:{x:M.left, y}, end:{x:M.left+W, y}, thickness:1.5, color:clrBorderStrong }); pg.drawLine({ start:{x:M.left, y}, end:{x:M.left+W, y}, thickness:1.5, color:clrBorderStrong });
y -= lh; y -= lh;
@ -1657,8 +1636,7 @@ async function generatePDF() {
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const a = document.createElement('a'); const a = document.createElement('a');
a.href = url; a.href = url;
const docDate = new Date().toISOString().slice(0,10); a.download = `reimbursement_${state.staff.replace(/\s+/g,'_')}_${state.periodFrom}_${state.periodTo}.pdf`;
a.download = `${state.staff.replace(/\s+/g,'_')}_${docDate}_Reimbursement.pdf`;
a.click(); a.click();
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
} }