Compare commits

...

15 commits

Author SHA1 Message Date
Claude
df8816dfb4
Redesign PDF header and fix layout issues
Some checks failed
/ mirror (push) Has been cancelled
- Header: remove initials box, match timesheet style (org name left,
  REIMBURSEMENT right, same size, muted subtitles below each)
- Replace thin hairline after header with thick accent-colour divider (2.5pt)
- Info strip (Staff/Period/Currency): fix unequal vertical padding — labels
  were crowded to top border; now symmetric using cap-height maths
- Item section header: replace bare left stripe with full-width surface-2 strip,
  equal visual padding above and below text, accent bar full height of strip
- Reduce excessive whitespace after receipt refs and before grand total divider
- Apply same equal-padding fix to continuation header (page 2+)

https://claude.ai/code/session_01JyuActqTJG5tuRQNLmT7fZ
2026-06-08 17:26:47 +00:00
Claude
1b6ea1b875
Fix PDF whitespace, remove monospace from figures, update download filename
- Increased row label gaps and inter-item spacing throughout PDF layout
- Changed all figure/number fonts from Courier (monospace) to Helvetica body
- Download filename now uses FULL_NAME_YYYY-MM-DD_Reimbursement.pdf convention

https://claude.ai/code/session_01JyuActqTJG5tuRQNLmT7fZ
2026-06-08 17:14:09 +00:00
Claude
387fb2cf90
Align action row to invoice app
- flex:2 / flex:2 / flex:6 ratios (was fixed percentages)
- kb-btn--lg on all three buttons
- margin-top 16px → 8px
- Download icon updated to stroke style

https://claude.ai/code/session_01JyuActqTJG5tuRQNLmT7fZ
2026-06-08 17:04:30 +00:00
Claude
9cf8bfeb89
Remove 'Claimant' card title
https://claude.ai/code/session_01JyuActqTJG5tuRQNLmT7fZ
2026-06-08 17:01:50 +00:00
Claude
1bb8eed0e6
Fix claimant card gap: push field content to bottom of each cell
Label+input pairs now stick to the bottom of their grid cell so the
labels sit flush above the inputs even when one label wraps.

https://claude.ai/code/session_01JyuActqTJG5tuRQNLmT7fZ
2026-06-08 17:00:14 +00:00
Claude
228ca1c269
Save saves unconditionally; validate feedback shown below buttons
- Save calls saveState() directly, no validation gate
- Validate feedback appears in a local div beneath the action row,
  not in the val-box at the top of the page

https://claude.ai/code/session_01JyuActqTJG5tuRQNLmT7fZ
2026-06-08 16:45:04 +00:00
Claude
e47dc9c4b3
Replace action row with Save | Validate | Download layout
- Save (20%) and Validate (20%) as ghost buttons, Download (60%) as primary
- Validate runs full validation and shows error list or a green success note
- Save retains existing onSave behaviour (soft validation + persist)

https://claude.ai/code/session_01JyuActqTJG5tuRQNLmT7fZ
2026-06-08 16:38:20 +00:00
Claude
ba64a27a1e
New form button uses accent soft colour
https://claude.ai/code/session_01JyuActqTJG5tuRQNLmT7fZ
2026-06-08 16:35:36 +00:00
Claude
faf83d034a
Align claimant fields to bottom so inputs share the same baseline
https://claude.ai/code/session_01JyuActqTJG5tuRQNLmT7fZ
2026-06-08 16:34:03 +00:00
Claude
da66515394
Zoom range 50–150% in 10% increments
https://claude.ai/code/session_01JyuActqTJG5tuRQNLmT7fZ
2026-06-08 16:26:11 +00:00
Claude
138c29a3a4
Default brand org name to 'kbenestad.reimburse'
https://claude.ai/code/session_01JyuActqTJG5tuRQNLmT7fZ
2026-06-08 16:19:41 +00:00
Claude
15ffddb57b
Match toolbar to invoice app
- kb-iconbtn 34px → 32px
- kb-seg button padding 6px 11px → 5px 10px
- Add kb-seg button:disabled rule
- toolbar margin-bottom 16px → 18px
- Language select uses .kb-select class with width:auto (remove .kb-toolbar select rule)
- Size seg: A−/A+ only with live % label, stepped scale array, buttons disable at limits

https://claude.ai/code/session_01JyuActqTJG5tuRQNLmT7fZ
2026-06-08 16:12:33 +00:00
Claude
f4eb0e7e4a
Set kb-wrap max-width to 980px
https://claude.ai/code/session_01JyuActqTJG5tuRQNLmT7fZ
2026-06-08 16:04:30 +00:00
Claude
95cc6c6feb
Complete favicon/PWA asset set
- Add favicon-16x16.png, favicon-32x32.png (renamed from favicon-32.png)
- Add favicon-48x48.png, favicon.ico (16+32 embedded)
- Add icon-192.png (downscaled from 512), icon-512.png
- Add site.webmanifest (theme #2f6fed, standard icon names)
- Update <head> links: SVG → 32 → 16 → ICO fallback chain,
  manifest link, theme-color meta

https://claude.ai/code/session_01JyuActqTJG5tuRQNLmT7fZ
2026-06-08 15:45:29 +00:00
Claude
c9f114d52c
Show app icon in brand when no org logo is configured
Matches the timesheet app: the blue squircle reimburse icon appears
next to the org name instead of the initials fallback. Uses var(--accent)
fill so it adapts to dark mode. Also used as fallback if logo image fails.

https://claude.ai/code/session_01JyuActqTJG5tuRQNLmT7fZ
2026-06-08 15:42:50 +00:00
8 changed files with 168 additions and 111 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 474 B

View file

Before

Width:  |  Height:  |  Size: 893 B

After

Width:  |  Height:  |  Size: 893 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

BIN
app/assets/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

BIN
app/assets/icon-192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

BIN
app/assets/icon-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View file

@ -0,0 +1,35 @@
{
"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,8 +6,12 @@
<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="alternate icon" href="assets/favicon-32.png" sizes="32x32"> <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="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>
@ -108,10 +112,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:960px;margin:0 auto;padding:24px 20px 56px;} .kb-wrap{max-width:980px;margin:0 auto;padding:24px 20px 56px;}
/* ── Toolbar ──────────────────────────────────────────────────────────────── */ /* ── Toolbar ──────────────────────────────────────────────────────────────── */
.kb-toolbar{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:16px;} .kb-toolbar{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:18px;}
.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;
@ -121,12 +125,13 @@ 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:6px 11px;border-radius:4px;cursor:pointer; padding:5px 10px;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){color:var(--text);} .kb-seg button:hover:not(.is-active):not(:disabled){color:var(--text);}
.kb-seg button:disabled{opacity:.4;cursor:not-allowed;}
.kb-iconbtn{ .kb-iconbtn{
display:inline-grid;place-items:center;width:34px;height:34px; display:inline-grid;place-items:center;width:32px;height:32px;
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;
} }
@ -141,8 +146,7 @@ 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;background:var(--accent-soft); display:grid;place-items:center;overflow:hidden;
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;}
@ -335,18 +339,6 @@ 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);}
@ -354,6 +346,7 @@ 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;}
@ -691,6 +684,15 @@ 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 = '';
@ -705,7 +707,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', {'aria-label':'Language'}); const langSel = el('select', {className:'kb-select', 'aria-label':'Language', style:'width:auto;padding:5px 28px 5px 8px;font-size:var(--fs-small)'});
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;
@ -716,20 +718,24 @@ function render() {
toolbar.appendChild(el('div', {className:'spacer'})); toolbar.appendChild(el('div', {className:'spacer'}));
// Font size // 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%');
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 sizeOpts = [{lbl:'A', scale:0.9}, {lbl:'A', scale:1}, {lbl:'A+', scale:1.12}]; const szDown = el('button', {type:'button', 'aria-label':'Smaller text'}, 'A');
let activeSizeIdx = 1; const szUp = el('button', {type:'button', 'aria-label':'Larger text'}, 'A+');
sizeOpts.forEach((s, i) => { function applyScale() {
const btn = el('button', {type:'button', className: i === activeSizeIdx ? 'is-active' : ''}, s.lbl); szDown.disabled = scaleIdx === 0;
btn.addEventListener('click', () => { szUp.disabled = scaleIdx === scales.length - 1;
$$('button', sizeSeg).forEach(b => b.classList.remove('is-active')); document.documentElement.style.setProperty('--font-scale', scales[scaleIdx]);
btn.classList.add('is-active'); szLabel.textContent = Math.round(scales[scaleIdx] * 100) + '%';
document.documentElement.style.setProperty('--font-scale', s.scale); }
}); szDown.addEventListener('click', () => { if (scaleIdx > 0) { scaleIdx--; applyScale(); } });
sizeSeg.appendChild(btn); szUp.addEventListener('click', () => { if (scaleIdx < scales.length-1) { scaleIdx++; applyScale(); } });
}); 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'});
@ -762,16 +768,14 @@ 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.onerror = function() { this.replaceWith(appIconSVG()); };
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.textContent = (CFG.organization || 'ORG').slice(0,2).toUpperCase(); logoBox.appendChild(appIconSVG());
} }
const orgSpan = el('span', {className:'org'}, CFG.organization || ''); const orgSpan = el('span', {className:'org'}, CFG.organization || 'kbenestad.reimburse');
orgSpan.appendChild(el('small', null, 'Expense reimbursement')); orgSpan.appendChild(el('small', null, 'Expense reimbursement'));
brand.append(logoBox, orgSpan); brand.append(logoBox, orgSpan);
@ -821,15 +825,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'}}); const claimGrid = el('div', {className:'kb-grid claim-grid', style:{gridTemplateColumns:'2fr 0.85fr 0.85fr 0.5fr', alignItems:'end'}});
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--ghost', style:{marginTop:'12px'}, onClick: onNewForm}, 'New form'); const newFormBtn = el('button', {type:'button', className:'kb-btn kb-btn--soft', style:{marginTop:'12px'}, onClick: onNewForm}, 'New form');
claimCard.appendChild(newFormBtn); claimCard.appendChild(newFormBtn);
wrap.appendChild(claimCard); wrap.appendChild(claimCard);
@ -862,12 +866,35 @@ function render() {
totals.appendChild(grandRow); totals.appendChild(grandRow);
actCard.appendChild(totals); actCard.appendChild(totals);
const actionRow = el('div', {style:{display:'flex', gap:'12px', marginTop:'16px', flexWrap:'wrap'}}); const actionRow = el('div', {style:{display:'flex', gap:'10px', marginTop:'8px'}});
const saveBtn = el('button', {className:'kb-btn kb-btn--soft', onClick: onSave}, 'Save form'); const actionFeedback = el('div', {style:{marginTop:'10px'}});
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`; const saveBtn = el('button', {className:'kb-btn kb-btn--ghost kb-btn--lg', style:{flex:'2'}}, 'Save');
actionRow.append(saveBtn, genBtn); saveBtn.addEventListener('click', () => saveState());
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);
@ -1354,17 +1381,18 @@ async function generatePDF() {
// Continuation header: light strip with staff + period // Continuation header: light strip with staff + period
function drawContHeader() { function drawContHeader() {
const stripH = szXs + lh + 14; const cPad = 7;
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 - 7; const labY = y - Math.round(cPad + szXs * 0.72);
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 });
mono(`${state.periodFrom} to ${state.periodTo}`, M.left + W * 0.45, valY); val(`${state.periodFrom} to ${state.periodTo}`, M.left + W * 0.45, valY);
y -= stripH + 12; y -= stripH + 12;
} }
@ -1372,47 +1400,38 @@ async function generatePDF() {
// ── Page 1 ──────────────────────────────────────────────────────────────── // ── Page 1 ────────────────────────────────────────────────────────────────
addPage(true); addPage(true);
// Header: brand block left | doc title right // Header: org name left | REIMBURSEMENT right (timesheet style)
const hdrTopY = y; const hdrTopY = y;
const boxSize = 44; 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
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, boxSize / logoImage.height, 1); const scale = Math.min(maxWLogo / logoImage.width, hdrNameSize * 1.6 / 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 {
// Initials box (surface-2 fill, border, accent initials) pg.drawText(CFG.organization || '', { x: M.left, y: hdrNameY, size: hdrNameSize, font: fontBold, color: clrText });
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 });
// Org name + subtitle const titleStr = 'REIMBURSEMENT';
const orgX = M.left + boxSize + 11; const titleW = fontBold.widthOfTextAtSize(titleStr, hdrNameSize);
pg.drawText(CFG.organization || '', { x: orgX, y: hdrTopY - 15, size: sz + 2, font: fontBold, color: clrText }); pg.drawText(titleStr, { x: M.left + W - titleW, y: hdrNameY, size: hdrNameSize, 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 = fontMono.widthOfTextAtSize(claimDate, szSm); const claimDateW = fontBody.widthOfTextAtSize(claimDate, szSm);
pg.drawText(claimDate, { x: M.left + W - claimDateW, y: hdrTopY - 15 - szLg - 4, size: szSm, font: fontMono, color: clrMuted }); pg.drawText(claimDate, { x: M.left + W - claimDateW, y: hdrSubtY, size: szSm, font: fontBody, color: clrMuted });
y = hdrTopY - boxSize - 10; y = hdrSubtY - szSm - 12;
// Hairline below header // Thick accent divider below header
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}, end:{x:M.left+W, y}, thickness:2.5, color:clrAccent });
y -= 12; y -= 16;
// Intro text // Intro text
if (CFG.intro) { if (CFG.intro) {
@ -1421,13 +1440,14 @@ async function generatePDF() {
y -= 6; y -= 6;
} }
// Staff / Period / Currency info block // Staff / Period / Currency info block — equal visual padding top and bottom
const infoH = szXs + lh + 14; const infoPad = 7;
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 - 7; const iLabY = y - Math.round(infoPad + szXs * 0.72);
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;
@ -1436,34 +1456,35 @@ 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 });
mono(`${state.periodFrom} to ${state.periodTo}`, M.left + iC2, iValY); val(`${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 + 14; y -= infoH + 20;
// ── Items ───────────────────────────────────────────────────────────────── // ── Items ─────────────────────────────────────────────────────────────────
state.items.forEach(item => { state.items.forEach(item => {
needSpace(lh * 7); needSpace(lh * 7);
// Section header: accent stripe + item name left, subtotal right // Section header strip — equal visual padding, accent left bar
accentStripe(y); const secH = sz + 16;
pg.drawText(item.name || '(no name)', { x: M.left + 8, y, size: sz, font: fontBold, color: clrTextSoft }); const secTextY = Math.round(y - secH / 2 + sz * 0.22);
const subStr = `${baseCur} ${fmtAmt(item._subtotal)}`; pg.drawRectangle({ x: M.left, y: y - secH, width: W, height: secH, color: clrSurface2 });
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 }); pg.drawLine({ start:{x:M.left, y}, end:{x:M.left+W, y}, thickness:0.5, color:clrBorder });
y -= 8; 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 subW = fontBold.widthOfTextAtSize(subStr, sz);
pg.drawText(subStr, { x: M.left + W - subW, y: secTextY, size: sz, font: fontBold, color: clrAccent });
y -= secH + 14;
// 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 -= 4;
pg.drawLine({ start:{x:M.left, y}, end:{x:M.left+W, y}, thickness:0.3, color:clrBorder });
y -= 8; y -= 8;
pg.drawLine({ start:{x:M.left, y}, end:{x:M.left+W, y}, thickness:0.3, color:clrBorder });
y -= 12;
} }
const c1=0, c2=W*0.22, c3=W*0.64; const c1=0, c2=W*0.22, c3=W*0.64;
@ -1475,17 +1496,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 + 4; y -= szXs + 6;
const dateInPeriod = isDateInPeriod(ln.date); const dateInPeriod = isDateInPeriod(ln.date);
mono((ln.date || '') + (dateInPeriod ? '' : ' (!)'), M.left + c1, y, val((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 = fontMono.widthOfTextAtSize(fxStr, sz); const fxW = fontBody.widthOfTextAtSize(fxStr, sz);
mono(fxStr, M.left + W - fxW, y); val(fxStr, M.left + W - fxW, y);
y -= lh + 4; y -= lh + 10;
// Row 2 — Description | Receipt | Amount // Row 2 — Description | Receipt | Amount
lbl('Description', M.left, y); lbl('Description', M.left, y);
@ -1493,19 +1514,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 + 4; y -= szXs + 6;
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 = fontMono.widthOfTextAtSize(amtStr, sz); const amtW = fontBody.widthOfTextAtSize(amtStr, sz);
mono(amtStr, M.left + W - amtW, y); val(amtStr, M.left + W - amtW, y);
y -= lh + 4; y -= lh + 10;
// 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 + 4; y -= szXs + 6;
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 || [];
@ -1513,7 +1534,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; y -= lh + 4;
} 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) => {
@ -1523,8 +1544,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 = fontMono.widthOfTextAtSize(suffix, szSm); const sfxW = fontBody.widthOfTextAtSize(suffix, szSm);
pg.drawText(suffix, { x: M.left + W - sfxW, y, size: szSm, font: fontMono, color: clrMuted }); pg.drawText(suffix, { x: M.left + W - sfxW, y, size: szSm, font: fontBody, color: clrMuted });
y -= lh; y -= lh;
}); });
} }
@ -1544,7 +1565,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 -= 6; y -= 2;
}); });
y -= 10; y -= 10;
@ -1552,7 +1573,7 @@ async function generatePDF() {
// Grand total // Grand total
needSpace(lh * 3 + 6); needSpace(lh * 3 + 6);
y -= 6; y -= 4;
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;
@ -1636,7 +1657,8 @@ 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;
a.download = `reimbursement_${state.staff.replace(/\s+/g,'_')}_${state.periodFrom}_${state.periodTo}.pdf`; const docDate = new Date().toISOString().slice(0,10);
a.download = `${state.staff.replace(/\s+/g,'_')}_${docDate}_Reimbursement.pdf`;
a.click(); a.click();
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
} }