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">
<title>Reimbursement</title>
<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="icon" href="assets/favicon-16x16.png" sizes="16x16" type="image/png">
<link rel="shortcut icon" href="assets/favicon.ico">
<link rel="alternate icon" href="assets/favicon-32.png" sizes="32x32">
<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/js-yaml@4.1.0/dist/js-yaml.min.js"></script>
<script>
@ -112,10 +108,10 @@ body{
.kb-mono{font-family:var(--font-mono);font-variant-numeric:tabular-nums;}
/* ── 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 ──────────────────────────────────────────────────────────────── */
.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-seg{
display:inline-flex;align-items:center;gap:2px;
@ -125,13 +121,12 @@ body{
.kb-seg button{
font:600 var(--fs-small)/1 var(--font-sans);color:var(--text-muted);
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:hover:not(.is-active):not(:disabled){color:var(--text);}
.kb-seg button:disabled{opacity:.4;cursor:not-allowed;}
.kb-seg button:hover:not(.is-active){color:var(--text);}
.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);
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 .logo{
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 .org{font-size:17px;font-weight:700;color:var(--text);letter-spacing:-0.01em;}
@ -339,6 +335,18 @@ body{
/* ── 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);}
@ -346,7 +354,6 @@ body{
/* ── Responsive ───────────────────────────────────────────────────────────── */
@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.claim-grid .kb-field{justify-content:flex-end;}
.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;}
@ -684,15 +691,6 @@ function makeSelect(options, value, onChange, placeholder) {
}
// ========== 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() {
const app = $('#app');
app.innerHTML = '';
@ -707,7 +705,7 @@ function render() {
// Language selector (only when CFG.languages has >1 entry) — goes first
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 => {
const code = typeof lang === 'object' ? lang.code : lang;
const name = typeof lang === 'object' ? lang.name : lang;
@ -718,24 +716,20 @@ function render() {
toolbar.appendChild(el('div', {className:'spacer'}));
// Font size — A / A+ with a live % label
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%');
// Font 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 szUp = el('button', {type:'button', 'aria-label':'Larger text'}, 'A+');
function applyScale() {
szDown.disabled = scaleIdx === 0;
szUp.disabled = scaleIdx === scales.length - 1;
document.documentElement.style.setProperty('--font-scale', scales[scaleIdx]);
szLabel.textContent = Math.round(scales[scaleIdx] * 100) + '%';
}
szDown.addEventListener('click', () => { if (scaleIdx > 0) { scaleIdx--; applyScale(); } });
szUp.addEventListener('click', () => { if (scaleIdx < scales.length-1) { scaleIdx++; applyScale(); } });
sizeSeg.append(szDown, szUp);
const sizeOpts = [{lbl:'A', scale:0.9}, {lbl:'A', scale:1}, {lbl:'A+', scale:1.12}];
let activeSizeIdx = 1;
sizeOpts.forEach((s, i) => {
const btn = el('button', {type:'button', className: i === activeSizeIdx ? 'is-active' : ''}, s.lbl);
btn.addEventListener('click', () => {
$$('button', sizeSeg).forEach(b => b.classList.remove('is-active'));
btn.classList.add('is-active');
document.documentElement.style.setProperty('--font-scale', s.scale);
});
sizeSeg.appendChild(btn);
});
toolbar.appendChild(sizeSeg);
toolbar.appendChild(szLabel);
// Theme toggle (single icon button: moon = light mode, sun = dark mode)
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.onerror = function() {
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';
logoBox.appendChild(img);
} 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'));
brand.append(logoBox, orgSpan);
@ -825,15 +821,15 @@ function render() {
});
const claimCard = el('div', {className:'kb-card'});
const claimGrid = el('div', {className:'kb-grid claim-grid', style:{gridTemplateColumns:'2fr 0.85fr 0.85fr 0.5fr', alignItems:'end'}});
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'}});
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--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);
wrap.appendChild(claimCard);
@ -866,35 +862,12 @@ function render() {
totals.appendChild(grandRow);
actCard.appendChild(totals);
const actionRow = el('div', {style:{display:'flex', gap:'10px', marginTop:'8px'}});
const actionFeedback = el('div', {style:{marginTop:'10px'}});
const saveBtn = el('button', {className:'kb-btn kb-btn--ghost kb-btn--lg', style:{flex:'2'}}, 'Save');
saveBtn.addEventListener('click', () => saveState());
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);
const actionRow = el('div', {style:{display:'flex', gap:'12px', marginTop:'16px', flexWrap:'wrap'}});
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});
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`;
actionRow.append(saveBtn, genBtn);
actCard.appendChild(actionRow);
wrap.appendChild(actCard);
app.appendChild(wrap);
@ -1381,18 +1354,17 @@ async function generatePDF() {
// Continuation header: light strip with staff + period
function drawContHeader() {
const cPad = 7;
const stripH = 2 * cPad + szXs + 5 + sz;
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 - Math.round(cPad + szXs * 0.72);
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 });
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;
}
@ -1400,38 +1372,47 @@ async function generatePDF() {
// ── Page 1 ────────────────────────────────────────────────────────────────
addPage(true);
// Header: org name left | REIMBURSEMENT right (timesheet style)
// Header: brand block left | doc title right
const hdrTopY = y;
const nowD = new Date();
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
const boxSize = 44;
if (logoImage) {
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;
pg.drawImage(logoImage, { x: M.left, y: hdrTopY - lhImg, width: lw, height: lhImg });
} 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';
const titleW = fontBold.widthOfTextAtSize(titleStr, hdrNameSize);
pg.drawText(titleStr, { x: M.left + W - titleW, y: hdrNameY, size: hdrNameSize, font: fontBold, color: clrText });
// 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 = fontBody.widthOfTextAtSize(claimDate, szSm);
pg.drawText(claimDate, { x: M.left + W - claimDateW, y: hdrSubtY, size: szSm, font: fontBody, color: clrMuted });
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 = hdrSubtY - szSm - 12;
y = hdrTopY - boxSize - 10;
// Thick accent divider below header
pg.drawLine({ start:{x:M.left, y}, end:{x:M.left+W, y}, thickness:2.5, color:clrAccent });
y -= 16;
// 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) {
@ -1440,14 +1421,13 @@ async function generatePDF() {
y -= 6;
}
// Staff / Period / Currency info block — equal visual padding top and bottom
const infoPad = 7;
const infoH = 2 * infoPad + szXs + 5 + sz;
// 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 });
const iLabY = y - Math.round(infoPad + szXs * 0.72);
const iLabY = y - 7;
const iValY = iLabY - szXs - 5;
const iC2 = W * 0.45, iC3 = W * 0.78;
@ -1456,35 +1436,34 @@ async function generatePDF() {
lbl('Currency', M.left + iC3, iLabY);
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 });
y -= infoH + 20;
y -= infoH + 14;
// ── Items ─────────────────────────────────────────────────────────────────
state.items.forEach(item => {
needSpace(lh * 7);
// Section header strip — equal visual padding, accent left bar
const secH = sz + 16;
const secTextY = Math.round(y - secH / 2 + sz * 0.22);
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 });
// 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 = fontBold.widthOfTextAtSize(subStr, sz);
pg.drawText(subStr, { x: M.left + W - subW, y: secTextY, size: sz, font: fontBold, color: clrAccent });
y -= secH + 14;
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 * 9);
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 });
y -= 12;
y -= 8;
}
const c1=0, c2=W*0.22, c3=W*0.64;
@ -1496,17 +1475,17 @@ async function generatePDF() {
const fxLblStr = 'FX RATE';
pg.drawText(fxLblStr, { x: M.left + W - fontBold.widthOfTextAtSize(fxLblStr, szXs), y,
size: szXs, font: fontBold, color: clrMuted });
y -= szXs + 6;
y -= szXs + 4;
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 });
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);
val(fxStr, M.left + W - fxW, y);
y -= lh + 10;
const fxW = fontMono.widthOfTextAtSize(fxStr, sz);
mono(fxStr, M.left + W - fxW, y);
y -= lh + 4;
// Row 2 — Description | Receipt | Amount
lbl('Description', M.left, y);
@ -1514,19 +1493,19 @@ async function generatePDF() {
const amtLblStr = 'AMOUNT';
pg.drawText(amtLblStr, { x: M.left + W - fontBold.widthOfTextAtSize(amtLblStr, szXs), y,
size: szXs, font: fontBold, color: clrMuted });
y -= szXs + 6;
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);
val(amtStr, M.left + W - amtW, y);
y -= lh + 10;
const amtW = fontMono.widthOfTextAtSize(amtStr, sz);
mono(amtStr, M.left + W - amtW, y);
y -= lh + 4;
// Row 3 — Account | Program
lbl('Account', M.left, 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);
const progs = ln.programs || [];
@ -1534,7 +1513,7 @@ async function generatePDF() {
const pe = progs[0] || {};
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);
y -= lh + 4;
y -= lh;
} else {
const lineBaseAmt = (() => { const a=parseFloat(ln.amount)||0, r=parseFloat(ln.fxRate)||1; return r>0?a/r:0; })();
progs.forEach((pe, pi) => {
@ -1544,8 +1523,8 @@ async function generatePDF() {
const progAmt = lineBaseAmt * pct / 100;
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 = fontBody.widthOfTextAtSize(suffix, szSm);
pg.drawText(suffix, { x: M.left + W - sfxW, y, size: szSm, font: fontBody, color: clrMuted });
const sfxW = fontMono.widthOfTextAtSize(suffix, szSm);
pg.drawText(suffix, { x: M.left + W - sfxW, y, size: szSm, font: fontMono, color: clrMuted });
y -= lh;
});
}
@ -1565,7 +1544,7 @@ async function generatePDF() {
explLines.forEach(line => { needSpace(lh); val(line, M.left, y); y -= lh; });
}
y -= 2;
y -= 6;
});
y -= 10;
@ -1573,7 +1552,7 @@ async function generatePDF() {
// Grand total
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 });
y -= lh;
@ -1657,8 +1636,7 @@ async function generatePDF() {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
const docDate = new Date().toISOString().slice(0,10);
a.download = `${state.staff.replace(/\s+/g,'_')}_${docDate}_Reimbursement.pdf`;
a.download = `reimbursement_${state.staff.replace(/\s+/g,'_')}_${state.periodFrom}_${state.periodTo}.pdf`;
a.click();
URL.revokeObjectURL(url);
}