Compare commits
15 commits
b1efad2ab4
...
df8816dfb4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
df8816dfb4 | ||
|
|
1b6ea1b875 | ||
|
|
387fb2cf90 | ||
|
|
9cf8bfeb89 | ||
|
|
1bb8eed0e6 | ||
|
|
228ca1c269 | ||
|
|
e47dc9c4b3 | ||
|
|
ba64a27a1e | ||
|
|
faf83d034a | ||
|
|
da66515394 | ||
|
|
138c29a3a4 | ||
|
|
15ffddb57b | ||
|
|
f4eb0e7e4a | ||
|
|
95cc6c6feb | ||
|
|
c9f114d52c |
BIN
app/assets/favicon-16x16.png
Normal file
|
After Width: | Height: | Size: 474 B |
|
Before Width: | Height: | Size: 893 B After Width: | Height: | Size: 893 B |
BIN
app/assets/favicon-48x48.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
app/assets/favicon.ico
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
app/assets/icon-192.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
app/assets/icon-512.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
35
app/assets/site.webmanifest
Normal 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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
244
app/index.html
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||