reimburse/app/index.html
Claude e08ffbf333
Right-align FX rate and Amount labels in PDF to sit above their values
Labels were left-aligned at c4 while values were flush-right, causing
them to not line up. Labels now share the same right edge as their values.

https://claude.ai/code/session_01MbwfxnjLA9KdFTrfzB55HM
2026-05-24 16:42:53 +00:00

1032 lines
43 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Reimbursement Form</title>
<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>
<style>
:root { --accent: #1a3a5c; --bg: #f5f6f8; --card: #fff; --text: #222; --muted: #666; --border: #d0d4da; --err: #c62828; }
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; background: var(--bg); color: var(--text); line-height: 1.5; }
.wrap { max-width: 920px; margin: 24px auto; background: var(--card); border-radius: 8px; box-shadow: 0 2px 12px rgba(0,0,0,.08); padding: 32px; }
.loading { text-align: center; padding: 80px; color: var(--muted); }
/* Header */
.form-hdr { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 24px; }
.form-hdr .logo img { max-height: 56px; }
.form-hdr .logo .org-name { font-size: 18px; font-weight: 700; color: var(--accent); }
.form-hdr .title { font-size: 18px; font-weight: 700; color: var(--accent); text-transform: uppercase; letter-spacing: .5px; text-align: right; }
/* Field rows */
.frow { display: flex; gap: 16px; margin-bottom: 16px; flex-wrap: wrap; }
.fgrp { display: flex; flex-direction: column; min-width: 120px; }
.fgrp.grow { flex: 1; }
.fgrp label { font-size: 11px; font-weight: 600; text-transform: uppercase; color: var(--muted); margin-bottom: 3px; letter-spacing: .3px; }
input, select, textarea { padding: 7px 10px; border: 1px solid var(--border); border-radius: 4px; font-size: 14px; font-family: inherit; background: #fff; }
input:focus, select:focus, textarea:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 2px rgba(26,58,92,.1); }
input[readonly] { background: #f0f1f3; color: var(--muted); }
textarea { resize: vertical; min-height: 48px; width: 100%; }
.input-err { border-color: var(--err) !important; }
.input-warn { border-color: #e65100 !important; background: #fff8f0 !important; }
/* Divider */
.divider { border: none; border-top: 2px solid var(--accent); margin: 20px 0; }
.divider-light { border: none; border-top: 1px solid var(--border); margin: 12px 0; }
/* Item block */
.item-blk { border: 1px solid var(--border); border-radius: 6px; padding: 20px; margin-bottom: 16px; background: #fafbfc; position: relative; }
.item-hdr { display: flex; justify-content: space-between; align-items: center; gap: 12px; margin-bottom: 2px; }
.item-hdr label { font-size: 12px; font-weight: 700; text-transform: uppercase; color: var(--accent); white-space: nowrap; }
.item-subtotal-row { display: flex; justify-content: flex-end; margin-bottom: 4px; }
.item-subtotal { font-weight: 600; font-size: 14px; white-space: nowrap; color: var(--accent); }
.item-name { width: 100%; box-sizing: border-box; }
/* Line block */
.line-blk { padding: 12px 0; }
.line-blk + .line-blk { border-top: 1px dashed var(--border); }
/* Buttons */
.btn { padding: 7px 14px; border: none; border-radius: 4px; cursor: pointer; font-size: 13px; font-weight: 500; font-family: inherit; }
.btn-add { background: transparent; color: var(--accent); border: 1px dashed var(--accent); }
.btn-add:hover { background: rgba(26,58,92,.04); }
.btn-rm { background: transparent; color: var(--err); font-size: 12px; padding: 4px 8px; }
.btn-rm:hover { text-decoration: underline; }
.btn-gen { background: var(--accent); color: #fff; font-size: 15px; padding: 12px 32px; width: 100%; margin-top: 16px; border-radius: 6px; }
.btn-gen:hover { opacity: .9; }
.btn-gen:disabled { opacity: .5; cursor: not-allowed; }
/* Totals */
.totals-row { display: flex; justify-content: space-between; align-items: center; padding: 12px 0; }
.grand-total { font-size: 17px; font-weight: 700; color: var(--accent); }
/* Receipt area */
.receipt-area { margin-top: 6px; padding: 8px 10px; background: #eef1f5; border-radius: 4px; }
.receipt-file { display: flex; align-items: center; gap: 8px; padding: 3px 0; font-size: 13px; }
.receipt-file .name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* Custom currency dropdown */
.cdd { position: relative; display: inline-block; }
.cdd-trigger { padding: 7px 10px; border: 1px solid var(--border); border-radius: 4px; background: #fff; cursor: pointer; font-size: 14px; font-family: inherit; text-align: left; min-width: 70px; }
.cdd-trigger:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 2px rgba(26,58,92,.1); }
.cdd-panel { display: none; position: absolute; top: calc(100% + 2px); left: 0; background: #fff; border: 1px solid var(--border); border-radius: 6px; box-shadow: 0 6px 20px rgba(0,0,0,.14); z-index: 100; min-width: 220px; max-height: 260px; overflow-y: auto; }
.cdd-panel.open { display: block; }
.cdd-opt { padding: 8px 12px; cursor: pointer; }
.cdd-opt:hover { background: #eef1f5; }
.cdd-opt .code { font-weight: 600; font-size: 14px; }
.cdd-opt .name { font-size: 11px; color: var(--muted); }
.cdd-opt + .cdd-opt { border-top: 1px solid #eee; }
/* Tooltip */
.tip { position: relative; cursor: help; }
.tip::after { content: attr(data-tip); position: absolute; bottom: calc(100% + 6px); left: 50%; transform: translateX(-50%); background: #333; color: #fff; padding: 5px 9px; border-radius: 4px; font-size: 11px; white-space: nowrap; opacity: 0; pointer-events: none; transition: opacity .15s; }
.tip:hover::after { opacity: 1; }
/* Validation summary */
.val-summary { background: #fce4e4; border: 1px solid var(--err); border-radius: 6px; padding: 14px 18px; margin-bottom: 16px; color: var(--err); font-size: 13px; line-height: 1.7; }
@media (max-width: 640px) {
.wrap { margin: 8px; padding: 16px; }
.frow { flex-direction: column; gap: 10px; }
.form-hdr { flex-direction: column; gap: 8px; }
}
</style>
</head>
<body>
<div id="app"><p class="loading">Loading configuration…</p></div>
<script>
(async function() {
'use strict';
// ========== UTILITIES ==========
const uid = () => Date.now().toString(36) + Math.random().toString(36).slice(2,8);
const $ = (sel, ctx) => (ctx || document).querySelector(sel);
const $$ = (sel, ctx) => [...(ctx || document).querySelectorAll(sel)];
const el = (tag, attrs, children) => {
const e = document.createElement(tag);
if (attrs) Object.entries(attrs).forEach(([k,v]) => {
if (k === 'className') e.className = v;
else if (k === 'style' && typeof v === 'object') Object.assign(e.style, v);
else if (k.startsWith('on')) e.addEventListener(k.slice(2).toLowerCase(), v);
else if (v != null && v !== false) e.setAttribute(k, v);
});
if (children) (Array.isArray(children) ? children : [children]).forEach(c => {
if (c == null) return;
e.appendChild(typeof c === 'string' ? document.createTextNode(c) : c);
});
return e;
};
function fmtAmt(n) {
const v = parseFloat(n);
return isNaN(v) ? '' : v.toLocaleString(undefined, {minimumFractionDigits:2, maximumFractionDigits:2});
}
function defaultPeriod() {
const d = new Date();
const isLastDay = d.getDate() === new Date(d.getFullYear(), d.getMonth() + 1, 0).getDate();
const y = isLastDay ? d.getFullYear() : (d.getMonth() === 0 ? d.getFullYear() - 1 : d.getFullYear());
const m = isLastDay ? d.getMonth() : (d.getMonth() === 0 ? 11 : d.getMonth() - 1);
const fmt = dt => `${dt.getFullYear()}-${String(dt.getMonth()+1).padStart(2,'0')}-${String(dt.getDate()).padStart(2,'0')}`;
return { from: fmt(new Date(y, m, 1)), to: fmt(new Date(y, m + 1, 0)) };
}
// ========== DATE HELPERS ==========
function isDateInPeriod(date) {
if (!date || !state.periodFrom || !state.periodTo) return true;
return date >= state.periodFrom && date <= state.periodTo;
}
function showWarningModal(msg) {
return new Promise(resolve => {
const overlay = el('div', {style:{position:'fixed',top:'0',right:'0',bottom:'0',left:'0',background:'rgba(0,0,0,.45)',zIndex:'9999',display:'flex',alignItems:'center',justifyContent:'center'}});
const box = el('div', {style:{background:'#fff',borderRadius:'8px',padding:'24px 28px',maxWidth:'400px',width:'90%',boxShadow:'0 8px 32px rgba(0,0,0,.25)'}});
const hdr = el('div', {style:{display:'flex',alignItems:'center',gap:'10px',marginBottom:'14px'}});
hdr.append(el('span', {style:{fontSize:'20px',color:'#e65100'}}, '⚠'), el('strong', {style:{fontSize:'15px',color:'#e65100'}}, 'Warning'));
const body = el('p', {style:{fontSize:'13px',lineHeight:'1.6',color:'var(--text)',marginBottom:'20px'}}, msg);
const footer = el('div', {style:{display:'flex',justifyContent:'flex-end'}});
const okBtn = el('button', {className:'btn btn-gen', style:{width:'auto',padding:'8px 28px',marginTop:'0'}}, 'OK');
okBtn.addEventListener('click', () => { overlay.remove(); resolve(); });
footer.appendChild(okBtn);
box.append(hdr, body, footer);
overlay.appendChild(box);
document.body.appendChild(overlay);
okBtn.focus();
});
}
// ========== CONFIG ==========
let CFG;
async function loadConfig() {
const res = await fetch('config.yml');
if (!res.ok) throw new Error('Cannot load config.yml');
CFG = jsyaml.load(await res.text());
document.documentElement.style.setProperty('--accent', CFG['accent-colour'] || '#1a3a5c');
}
// ========== STATE ==========
const state = { staff: '', periodFrom: '', periodTo: '', baseCurrency: '', fxRateMemory: {}, items: [], _grandTotal: 0 };
function newItem() { return { id: uid(), name: '', lines: [newLine()], _subtotal: 0 }; }
function newLine() {
return {
id: uid(), date: '', description: '', currency: state.baseCurrency,
fxRate: '1.00000', vendor: '', hasReceipt: true, receipts: [],
noReceiptExplanation: '', amount: '', account: '',
programs: [{ program: '', percent: '', programOther: '' }]
};
}
// ========== CALCULATIONS ==========
function recalc() {
let grand = 0;
state.items.forEach(item => {
let sub = 0;
item.lines.forEach(ln => {
const amt = parseFloat(ln.amount) || 0;
const rate = parseFloat(ln.fxRate) || 1;
sub += rate > 0 ? amt / rate : 0;
});
item._subtotal = sub;
grand += sub;
const se = $(`#sub-${item.id}`);
if (se) se.textContent = `${state.baseCurrency} ${fmtAmt(sub)}`;
});
state._grandTotal = grand;
const ge = $('#grand-total');
if (ge) ge.textContent = `${state.baseCurrency} ${fmtAmt(grand)}`;
}
// ========== CURRENCY DROPDOWN ==========
function makeCDD(currencies, value, onChange) {
const wrap = el('div', {className:'cdd'});
const trigger = el('button', {className:'cdd-trigger', type:'button'}, value || 'Select');
const panel = el('div', {className:'cdd-panel'});
currencies.forEach(c => {
const opt = el('div', {className:'cdd-opt', onClick: () => {
trigger.textContent = c.code;
panel.classList.remove('open');
onChange(c.code);
}}, [el('div', {className:'code'}, c.code), el('div', {className:'name'}, c.name)]);
panel.appendChild(opt);
});
trigger.addEventListener('click', e => { e.stopPropagation(); panel.classList.toggle('open'); });
document.addEventListener('click', () => panel.classList.remove('open'));
wrap.append(trigger, panel);
wrap._setValue = code => { trigger.textContent = code; };
return wrap;
}
// ========== SELECT HELPER ==========
function makeSelect(options, value, onChange, placeholder) {
const s = el('select');
if (placeholder) s.appendChild(el('option', {value:'', disabled:'', selected: !value ? '' : undefined}, placeholder));
options.forEach(o => {
const opt = el('option', {value: o}, o);
if (o === value) opt.selected = true;
s.appendChild(opt);
});
s.addEventListener('change', () => onChange(s.value));
return s;
}
// ========== FORM RENDERING ==========
function render() {
const app = $('#app');
app.innerHTML = '';
const wrap = el('div', {className:'wrap'});
// Validation summary placeholder
const valBox = el('div', {id:'val-box'});
wrap.appendChild(valBox);
// Header
const hdr = el('div', {className:'form-hdr'});
const logoDiv = el('div', {className:'logo'});
if (CFG.logo === true || CFG.logo === 'yes') {
const img = el('img', {alt: CFG.organization || ''});
img.src = 'assets/logo.png';
img.onerror = function() { this.src = 'assets/logo.jpg'; this.onerror = function() {
this.replaceWith(el('span', {className:'org-name'}, CFG.organization || ''));
}; };
if (CFG['logo-maxwidth']) img.style.maxWidth = CFG['logo-maxwidth'] + 'cm';
logoDiv.appendChild(img);
} else {
logoDiv.appendChild(el('span', {className:'org-name'}, CFG.organization || ''));
}
hdr.append(logoDiv, el('div', {className:'title'}, 'Reimbursement Form'));
wrap.appendChild(hdr);
// Staff / Period / Currency row
const savedStaff = localStorage.getItem('reimb-staff') || '';
if (savedStaff && !state.staff) state.staff = savedStaff;
const period = defaultPeriod();
if (!state.periodFrom) state.periodFrom = period.from;
if (!state.periodTo) state.periodTo = period.to;
const staffInput = el('input', {type:'text', value: state.staff, placeholder:'Full name'});
staffInput.addEventListener('input', () => { state.staff = staffInput.value; localStorage.setItem('reimb-staff', staffInput.value); });
const fromInput = el('input', {type:'date', value: state.periodFrom});
fromInput.addEventListener('change', () => { state.periodFrom = fromInput.value; });
const toInput = el('input', {type:'date', value: state.periodTo});
toInput.addEventListener('change', () => { state.periodTo = toInput.value; });
const baseCurDD = makeCDD(CFG.currencies || [], state.baseCurrency, code => {
state.baseCurrency = code;
const box = $('#items-box');
if (box) {
box.innerHTML = '';
state.items.forEach(item => box.appendChild(renderItem(item)));
}
recalc();
});
wrap.appendChild(el('div', {className:'frow'}, [
el('div', {className:'fgrp grow'}, [el('label', null, 'Staff'), staffInput]),
el('div', {className:'fgrp'}, [el('label', null, 'Period from'), fromInput]),
el('div', {className:'fgrp'}, [el('label', null, 'to'), toInput]),
el('div', {className:'fgrp'}, [el('label', null, 'Base currency'), baseCurDD]),
]));
wrap.appendChild(el('hr', {className:'divider'}));
// Items container
const itemsBox = el('div', {id:'items-box'});
state.items.forEach(item => itemsBox.appendChild(renderItem(item)));
wrap.appendChild(itemsBox);
// Add item + Grand total row
const addItemBtn = el('button', {className:'btn btn-add', onClick: () => {
const item = newItem();
state.items.push(item);
itemsBox.appendChild(renderItem(item));
recalc();
}}, '+ Add item / project / travel');
wrap.appendChild(el('div', {className:'totals-row'}, [
addItemBtn,
el('div', {className:'grand-total'}, [
'Total reimbursement claim: ',
el('span', {id:'grand-total'}, `${state.baseCurrency} ${fmtAmt(0)}`)
])
]));
// Generate button
wrap.appendChild(el('button', {className:'btn btn-gen', id:'gen-btn', onClick: onGenerate}, 'Generate Reimbursement Form'));
app.appendChild(wrap);
recalc();
}
function renderItem(item) {
const blk = el('div', {className:'item-blk', id:`item-${item.id}`});
// Item header
const nameIn = el('input', {className:'item-name', type:'text', value: item.name, placeholder:'Item / project / travel name'});
nameIn.addEventListener('input', () => { item.name = nameIn.value; });
const rmBtn = el('button', {className:'btn btn-rm', onClick: () => {
state.items = state.items.filter(i => i.id !== item.id);
blk.remove();
recalc();
}}, '✕ Remove');
blk.appendChild(el('div', {className:'item-hdr'}, [
el('label', null, 'Item / Project / Travel'),
rmBtn
]));
blk.appendChild(nameIn);
blk.appendChild(el('div', {className:'item-subtotal-row'}, [
el('span', {className:'item-subtotal'}, [
'Subtotal: ', el('span', {id:`sub-${item.id}`}, `${state.baseCurrency} ${fmtAmt(0)}`)
])
]));
// Lines container
const linesBox = el('div', {id:`lines-${item.id}`});
item.lines.forEach(ln => linesBox.appendChild(renderLine(ln, item)));
blk.appendChild(linesBox);
// Add line button
blk.appendChild(el('button', {className:'btn btn-add', style:{marginTop:'10px'}, onClick: () => {
const ln = newLine();
item.lines.push(ln);
linesBox.appendChild(renderLine(ln, item));
recalc();
}}, '+ Add line'));
return blk;
}
function renderLine(ln, item) {
const blk = el('div', {className:'line-blk', id:`line-${ln.id}`});
let progAreaEl;
const currencies = CFG.currencies || [];
const baseCur = state.baseCurrency;
// Row 1: Date, Vendor, Currency, FX Rate
const dateIn = el('input', {type:'date', value: ln.date, style:{width:'150px'}});
if (ln.date && !isDateInPeriod(ln.date)) dateIn.classList.add('input-warn');
dateIn.addEventListener('change', async () => {
ln.date = dateIn.value;
if (ln.date && !isDateInPeriod(ln.date)) {
dateIn.classList.add('input-warn');
await showWarningModal('The date of the expense is not within the period you have chosen at the top of the form. You can continue with this date, but it will be flagged on the form.');
} else {
dateIn.classList.remove('input-warn');
}
});
const vendIn = el('input', {type:'text', value: ln.vendor, placeholder:'Vendor name'});
vendIn.addEventListener('input', () => { ln.vendor = vendIn.value; });
const curDD = makeCDD(currencies, ln.currency, code => {
ln.currency = code;
fxIn.dataset.tip = `Units of ${code} per 1 ${baseCur}`;
if (code === baseCur) {
ln.fxRate = '1.00000'; fxIn.value = '1.00000'; fxIn.readOnly = true;
} else {
fxIn.readOnly = false;
const mem = state.fxRateMemory[code];
if (mem) { ln.fxRate = mem; fxIn.value = mem; }
else if (ln.fxRate === '1.00000') { ln.fxRate = ''; fxIn.value = ''; }
}
recalc();
if (progAreaEl) progAreaEl._refresh();
});
const fxIn = el('input', {type:'text', value: ln.fxRate, style:{width:'120px', textAlign:'right'}, placeholder:'0.00000'});
fxIn.readOnly = ln.currency === baseCur;
fxIn.className = 'tip';
fxIn.dataset.tip = `Units of ${ln.currency || '?'} per 1 ${baseCur}`;
fxIn.addEventListener('input', () => {
ln.fxRate = fxIn.value;
const rate = parseFloat(fxIn.value);
if (ln.currency && ln.currency !== baseCur && rate > 0) state.fxRateMemory[ln.currency] = fxIn.value;
recalc();
if (progAreaEl) progAreaEl._refresh();
});
blk.appendChild(el('div', {className:'frow'}, [
el('div', {className:'fgrp'}, [el('label', null, 'Date'), dateIn]),
el('div', {className:'fgrp grow'}, [el('label', null, 'Vendor'), vendIn]),
el('div', {className:'fgrp'}, [el('label', null, 'Currency'), curDD]),
el('div', {className:'fgrp'}, [el('label', null, 'FX rate'), fxIn]),
]));
// Row 2: Description, Receipt, Amount
const descIn = el('input', {type:'text', value: ln.description, placeholder:'Description'});
descIn.addEventListener('input', () => { ln.description = descIn.value; });
const receiptSel = makeSelect(['Yes','No'], ln.hasReceipt ? 'Yes' : 'No', v => {
ln.hasReceipt = v === 'Yes';
receiptArea.innerHTML = '';
receiptArea.appendChild(buildReceiptArea(ln));
});
const amtIn = el('input', {type:'text', value: ln.amount, style:{width:'120px', textAlign:'right'}, placeholder:'0.00'});
amtIn.addEventListener('input', () => { ln.amount = amtIn.value; recalc(); if (progAreaEl) progAreaEl._refresh(); });
blk.appendChild(el('div', {className:'frow'}, [
el('div', {className:'fgrp grow'}, [el('label', null, 'Description'), descIn]),
el('div', {className:'fgrp'}, [el('label', null, 'Receipt'), receiptSel]),
el('div', {className:'fgrp'}, [el('label', null, 'Amount'), amtIn]),
]));
// Row 3: Account, Program
const acctSel = makeSelect(CFG.accounts || [], ln.account, v => { ln.account = v; }, 'Select account');
progAreaEl = buildProgramArea(ln);
const progGrp = el('div', {className:'fgrp grow'}, [el('label', null, 'Program'), progAreaEl]);
blk.appendChild(el('div', {className:'frow'}, [
el('div', {className:'fgrp grow'}, [el('label', null, 'Account'), acctSel]),
progGrp,
]));
// Receipt area
const receiptArea = el('div');
receiptArea.appendChild(buildReceiptArea(ln));
blk.appendChild(receiptArea);
// Remove line
const rmBtn = el('button', {className:'btn btn-rm', style:{marginTop:'6px'}, onClick: () => {
item.lines = item.lines.filter(l => l.id !== ln.id);
blk.remove();
recalc();
}}, '✕ Remove line');
blk.appendChild(rmBtn);
return blk;
}
function buildReceiptArea(ln) {
const area = el('div', {className:'receipt-area'});
if (ln.hasReceipt) {
// List uploaded files
ln.receipts.forEach((r, i) => {
area.appendChild(el('div', {className:'receipt-file'}, [
el('span', {className:'name'}, r.name),
el('button', {className:'btn btn-rm', onClick: () => {
ln.receipts.splice(i, 1);
area.replaceWith(buildReceiptArea(ln));
}}, '✕')
]));
});
// Add button
const fileIn = el('input', {type:'file', accept:'.pdf,.jpg,.jpeg,.png', style:{display:'none'}});
fileIn.addEventListener('change', async () => {
for (const f of fileIn.files) {
ln.receipts.push({ name: f.name, type: f.type, data: await f.arrayBuffer() });
}
area.replaceWith(buildReceiptArea(ln));
});
area.appendChild(el('button', {className:'btn btn-add', style:{marginTop:'4px'}, onClick: () => fileIn.click()}, '+ Add receipt'));
area.appendChild(fileIn);
} else {
const ta = el('textarea', {placeholder:'Explain why there is no receipt', rows:'2'});
ta.value = ln.noReceiptExplanation || '';
ta.addEventListener('input', () => { ln.noReceiptExplanation = ta.value; });
area.appendChild(el('label', {style:{fontSize:'12px', fontWeight:'600', color:'var(--muted)'}}, 'Explain why there is no receipt:'));
area.appendChild(ta);
}
return area;
}
function buildProgramArea(ln) {
const wrapper = el('div');
const progOptions = CFG.programs || [];
function rebuild() {
while (wrapper.firstChild) wrapper.removeChild(wrapper.firstChild);
const isMulti = ln.programs.length > 1;
const amtSpans = [];
let totalSpan = null;
function getBaseAmt() {
const amt = parseFloat(ln.amount) || 0;
const rate = parseFloat(ln.fxRate) || 1;
return rate > 0 ? amt / rate : 0;
}
function updateDerived() {
const base = getBaseAmt();
amtSpans.forEach((span, i) => {
const pct = parseFloat((ln.programs[i] || {}).percent) || 0;
span.textContent = `${state.baseCurrency} ${fmtAmt(base * pct / 100)}`;
});
if (totalSpan) {
const sum = ln.programs.reduce((s, pe) => s + (parseFloat(pe.percent) || 0), 0);
totalSpan.textContent = sum.toFixed(2) + '%';
totalSpan.style.color = sum < 99.995 ? '#c6a800' : sum <= 100.005 ? '#2e7d32' : '#c62828';
}
}
ln.programs.forEach((pe, pi) => {
const progSel = makeSelect(progOptions, pe.program, v => {
pe.program = v;
otherIn.style.display = v === 'Other' ? '' : 'none';
}, 'Select program');
progSel.style.flex = '1';
const otherIn = el('input', {type:'text', value: pe.programOther, placeholder:'Specify program',
style:{display: pe.program === 'Other' ? '' : 'none', marginTop:'4px', width:'100%'}});
otherIn.addEventListener('input', () => { pe.programOther = otherIn.value; });
if (!isMulti) {
const addBtn = el('button', {type:'button', className:'btn btn-add'}, '+ Add program');
addBtn.addEventListener('click', () => {
ln.programs.push({program:'', percent:'', programOther:''});
rebuild();
});
const row = el('div', {style:{display:'flex', gap:'8px', alignItems:'center'}});
row.append(progSel, addBtn);
wrapper.append(row, otherIn);
} else {
const pctIn = el('input', {type:'number', min:'0', max:'100', step:'0.01',
value: pe.percent, style:{width:'70px', textAlign:'right'}});
pctIn.addEventListener('input', () => { pe.percent = pctIn.value; updateDerived(); });
const amtSpan = el('span', {style:{fontSize:'13px', color:'var(--muted)', whiteSpace:'nowrap'}});
amtSpans.push(amtSpan);
const rmBtn = el('button', {type:'button', className:'btn btn-rm', style:{padding:'3px 7px'}}, '×');
rmBtn.addEventListener('click', () => {
ln.programs.splice(pi, 1);
rebuild();
});
const row = el('div', {style:{display:'flex', gap:'8px', alignItems:'center', marginBottom:'4px'}});
row.append(progSel,
el('span', {style:{fontSize:'13px', color:'var(--muted)', whiteSpace:'nowrap'}}, 'Percent:'),
pctIn,
el('span', {style:{fontSize:'13px'}}, '%'),
el('span', {style:{fontSize:'13px', color:'var(--muted)'}}, ''),
amtSpan,
rmBtn);
wrapper.append(row, otherIn);
}
});
if (isMulti) {
totalSpan = el('span', {style:{fontWeight:'600', fontSize:'13px'}});
const totalRow = el('div',
{style:{display:'flex', justifyContent:'flex-end', alignItems:'center', gap:'6px', marginTop:'4px', marginBottom:'4px'}},
[el('span', {style:{fontSize:'13px', color:'var(--muted)'}}, 'Total percent:'), totalSpan]);
wrapper.appendChild(totalRow);
const addBtn = el('button', {type:'button', className:'btn btn-add', style:{marginTop:'2px'}}, '+ Add program');
addBtn.addEventListener('click', () => {
ln.programs.push({program:'', percent:'', programOther:''});
rebuild();
});
wrapper.appendChild(addBtn);
}
updateDerived();
}
wrapper._refresh = rebuild;
rebuild();
return wrapper;
}
// ========== VALIDATION ==========
function validate() {
const errs = [];
if (!state.staff.trim()) errs.push('Staff name is required.');
if (!state.periodFrom || !state.periodTo) errs.push('Period dates are required.');
if (state.items.length === 0) errs.push('Add at least one item.');
state.items.forEach((item, ii) => {
const idx = ii + 1;
if (!item.name.trim()) errs.push(`Item ${idx}: Name is required.`);
if (item.lines.length === 0) errs.push(`Item ${idx}: Add at least one line.`);
item.lines.forEach((ln, li) => {
const lx = `Item ${idx}, line ${li+1}`;
if (!ln.date) errs.push(`${lx}: Date is required.`);
if (!ln.description.trim()) errs.push(`${lx}: Description is required.`);
if (!ln.vendor.trim()) errs.push(`${lx}: Vendor is required.`);
const amt = parseFloat(ln.amount);
if (isNaN(amt) || amt <= 0) errs.push(`${lx}: Amount must be a positive number.`);
if (ln.currency !== state.baseCurrency) {
const rate = parseFloat(ln.fxRate);
if (isNaN(rate) || rate <= 0) errs.push(`${lx}: FX rate must be a positive number.`);
}
if (!ln.account) errs.push(`${lx}: Account is required.`);
const progs = ln.programs || [];
if (progs.length === 0 || !progs[0].program) {
errs.push(`${lx}: Program is required.`);
} else {
progs.forEach((pe, pi) => {
if (!pe.program) errs.push(`${lx}: Program ${pi+1} selection is required.`);
if (pe.program === 'Other' && !pe.programOther.trim()) errs.push(`${lx}: Please specify program ${pi+1}.`);
});
if (progs.length > 1) {
progs.forEach((pe, pi) => {
const pct = parseFloat(pe.percent);
if (isNaN(pct) || pct <= 0) errs.push(`${lx}: Percent for program ${pi+1} must be a positive number.`);
});
const total = progs.reduce((s, pe) => s + (parseFloat(pe.percent) || 0), 0);
if (Math.abs(total - 100) > 0.005) errs.push(`${lx}: Program percentages must total 100% (currently ${total.toFixed(2)}%).`);
}
}
if (ln.hasReceipt && ln.receipts.length === 0) errs.push(`${lx}: Upload at least one receipt, or select "No" for receipt.`);
if (!ln.hasReceipt && !ln.noReceiptExplanation.trim()) errs.push(`${lx}: Explain why there is no receipt.`);
});
});
return errs;
}
// ========== PDF ENGINE ==========
async function generatePDF() {
const { PDFDocument, rgb, StandardFonts } = PDFLib;
const doc = await PDFDocument.create();
const pageW = CFG['page-size'] === 'letter' ? 612 : 595.28;
const pageH = CFG['page-size'] === 'letter' ? 792 : 841.89;
const M = { top: 50, bottom: 65, left: 50, right: 50 };
const W = pageW - M.left - M.right;
// Fonts
const fontBody = await doc.embedFont(StandardFonts.Helvetica);
const fontBold = await doc.embedFont(StandardFonts.HelveticaBold);
const fontMono = await doc.embedFont(StandardFonts.Courier);
const sz = CFG['font-size'] || 10;
const szSm = sz - 1;
const szLg = sz + 4;
const szXl = sz + 6;
const lh = sz + 4; // line height
// Colors
const accent = parseHex(CFG['accent-colour'] || '#1a3a5c');
const black = rgb(0.13, 0.13, 0.13);
const gray = rgb(0.45, 0.45, 0.45);
const lineCol = rgb(0.75, 0.75, 0.75);
const baseCur = state.baseCurrency;
// Logo
let logoImage = null;
if (CFG.logo === true || CFG.logo === 'yes') {
logoImage = await loadLogo(doc);
}
// State for page tracking
const pages = [];
let pg, y;
const receiptRefs = []; // { pageIdx, x, y, receiptKey }
let justBroke = false;
function addPage(isFirst) {
pg = doc.addPage([pageW, pageH]);
pages.push(pg);
y = pageH - M.top;
justBroke = true;
if (!isFirst) drawContHeader();
}
function needSpace(h) {
justBroke = false;
if (y - h < M.bottom) { addPage(false); }
}
function drawContHeader() {
pg.drawText(state.staff, { x: M.left, y, size: sz, font: fontBold, color: black });
const periodStr = `Period: ${state.periodFrom} to ${state.periodTo}`;
const pw = fontBody.widthOfTextAtSize(periodStr, sz);
pg.drawText(periodStr, { x: M.left + W - pw, y, size: sz, font: fontBody, color: gray });
y -= lh + 2;
pg.drawLine({ start:{x:M.left, y}, end:{x:M.left+W, y}, thickness:1.5, color:accent });
y -= lh;
}
// ---- PAGE 1 ----
addPage(true);
// Logo + title
const mm10 = 10 * 2.83465; // 10 mm in points
if (logoImage) {
const maxW = (CFG['logo-maxwidth'] || 4) * 28.3465;
const scale = Math.min(maxW / logoImage.width, 50 / logoImage.height, 1);
const lw = logoImage.width * scale, lhh = logoImage.height * scale;
// Absolute position: 10 mm from top-left, above all other content
const logoTop = pageH - mm10;
pg.drawImage(logoImage, { x: mm10, y: logoTop - lhh, width: lw, height: lhh });
// Push cursor below logo with a small gap before remaining header elements
y = Math.min(y, logoTop - lhh - 8);
} else if (CFG.organization) {
pg.drawText(CFG.organization, { x: M.left, y, size: szLg, font: fontBold, color: accent });
y -= szLg + 8;
}
const titleStr = 'REIMBURSEMENT FORM';
const tw = fontBold.widthOfTextAtSize(titleStr, szLg);
pg.drawText(titleStr, { x: M.left + W - tw, y, size: szLg, font: fontBold, color: accent });
y -= szLg + 8;
// Intro text
if (CFG.intro) {
const introLines = wrapText(CFG.intro, fontBody, sz, W);
introLines.forEach(line => { pg.drawText(line, {x:M.left, y, size:sz, font:fontBody, color:gray}); y -= lh; });
y -= 4;
}
// Staff / Period / Currency
const col2 = W * 0.5;
const col3 = W * 0.8;
pg.drawText('Staff', {x:M.left, y, size:szSm-1, font:fontBold, color:gray});
pg.drawText('Period', {x:M.left+col2, y, size:szSm-1, font:fontBold, color:gray});
pg.drawText('Currency', {x:M.left+col3, y, size:szSm-1, font:fontBold, color:gray});
y -= lh;
pg.drawText(state.staff, {x:M.left, y, size:sz, font:fontBody, color:black});
pg.drawText(`${state.periodFrom} to ${state.periodTo}`, {x:M.left+col2, y, size:sz, font:fontBody, color:black});
pg.drawText(baseCur, {x:M.left+col3, y, size:sz, font:fontBold, color:black});
y -= lh + 6;
// Divider
pg.drawLine({start:{x:M.left,y}, end:{x:M.left+W,y}, thickness:1.5, color:accent});
y -= lh;
// Items
state.items.forEach(item => {
// Item header
needSpace(lh * 6); // need room for at least header + one line
pg.drawText('ITEM / PROJECT / TRAVEL', {x:M.left, y, size:szSm, font:fontBold, color:accent});
const subStr = `Subtotal: ${baseCur} ${fmtAmt(item._subtotal)}`;
const subW = fontBold.widthOfTextAtSize(subStr, sz);
pg.drawText(subStr, {x:M.left+W-subW, y, size:sz, font:fontBold, color:accent});
y -= lh;
pg.drawText(item.name, {x:M.left, y, size:sz, font:fontBody, color:black});
y -= lh + 4;
// Lines
item.lines.forEach((ln, li) => {
needSpace(lh * 7);
if (li > 0 && !justBroke) {
y -= 4;
pg.drawLine({start:{x:M.left,y}, end:{x:M.left+W,y}, thickness:0.3, color:lineCol});
y -= 8;
}
const c1=0, c2=W*0.22, c3=W*0.68, c4=W*0.82;
// Row 1 labels: Date | Vendor | Currency | FX rate
pg.drawText('Date', {x:M.left+c1, y, size:szSm-1, font:fontBold, color:gray});
pg.drawText('Vendor', {x:M.left+c2, y, size:szSm-1, font:fontBold, color:gray});
pg.drawText('Currency', {x:M.left+c3, y, size:szSm-1, font:fontBold, color:gray});
const fxLbl = 'FX rate'; pg.drawText(fxLbl, {x:M.left+W-fontBold.widthOfTextAtSize(fxLbl,szSm-1), y, size:szSm-1, font:fontBold, color:gray});
y -= lh;
// Row 1 values
const dateInPeriod = isDateInPeriod(ln.date);
const dateColor = dateInPeriod ? black : rgb(0.9, 0.33, 0);
pg.drawText((ln.date || '') + (dateInPeriod ? '' : ' (!)'), {x:M.left+c1, y, size:sz, font:fontBody, color:dateColor});
pg.drawText(truncate(ln.vendor, fontBody, sz, (c3-c2)-8), {x:M.left+c2, y, size:sz, font:fontBody, color:black});
pg.drawText(ln.currency, {x:M.left+c3, y, size:sz, font:fontBody, color:black});
const fxStr = ln.currency === baseCur ? '' : parseFloat(ln.fxRate).toFixed(5);
const fxW = fontBody.widthOfTextAtSize(fxStr, sz);
pg.drawText(fxStr, {x:M.left+W-fxW, y, size:sz, font:fontBody, color:black});
y -= lh + 2;
// Row 2 labels: Description | Receipt | Amount
pg.drawText('Description', {x:M.left, y, size:szSm-1, font:fontBold, color:gray});
pg.drawText('Receipt', {x:M.left+c3, y, size:szSm-1, font:fontBold, color:gray});
const amtLbl = 'Amount'; pg.drawText(amtLbl, {x:M.left+W-fontBold.widthOfTextAtSize(amtLbl,szSm-1), y, size:szSm-1, font:fontBold, color:gray});
y -= lh;
// Row 2 values
pg.drawText(truncate(ln.description, fontBody, sz, (c3)-8), {x:M.left, y, size:sz, font:fontBody, color:black});
pg.drawText(ln.hasReceipt ? 'Yes' : 'No', {x:M.left+c3, y, size:sz, font:fontBody, color:black});
const amtStr = `${ln.currency} ${fmtAmt(ln.amount)}`;
const amtW = fontBody.widthOfTextAtSize(amtStr, sz);
pg.drawText(amtStr, {x:M.left+W-amtW, y, size:sz, font:fontBody, color:black});
y -= lh + 2;
// Row 3 labels
pg.drawText('Account', {x:M.left, y, size:szSm-1, font:fontBold, color:gray});
pg.drawText('Program', {x:M.left+W*0.5, y, size:szSm-1, font:fontBold, color:gray});
y -= lh;
pg.drawText(ln.account || '', {x:M.left, y, size:sz, font:fontBody, color:black});
const progs = ln.programs || [];
if (progs.length <= 1) {
const pe = progs[0] || {};
const progStr = pe.program === 'Other' ? `Other: ${pe.programOther}` : (pe.program || '');
pg.drawText(truncate(progStr, fontBody, sz, W * 0.5 - 8), {x:M.left+W*0.5, y, size:sz, font:fontBody, color:black});
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) => {
if (pi > 0) { needSpace(lh); }
const progStr = pe.program === 'Other' ? `Other: ${pe.programOther}` : (pe.program || '');
const pct = parseFloat(pe.percent) || 0;
const progAmt = lineBaseAmt * pct / 100;
const suffix = ` ${pct.toFixed(2)}% ${baseCur} ${fmtAmt(progAmt)}`;
pg.drawText(truncate(progStr, fontBody, sz, W * 0.45 - 8), {x:M.left+W*0.5, y, size:sz, font:fontBody, color:black});
const sfxW = fontBody.widthOfTextAtSize(suffix, szSm);
pg.drawText(suffix, {x:M.left+W-sfxW, y, size:szSm, font:fontBody, color:gray});
y -= lh;
});
}
// Receipt reference or explanation
if (ln.hasReceipt && ln.receipts.length > 0) {
ln.receipts.forEach((r, ri) => {
const key = `${ln.id}-${ri}`;
const refX = M.left + W * 0.6;
receiptRefs.push({ pageIdx: pages.length - 1, x: refX, y, key });
y -= lh;
});
} else if (!ln.hasReceipt) {
needSpace(lh * 2);
pg.drawText('Explanation for no receipt:', {x:M.left, y, size:szSm-1, font:fontBold, color:gray});
y -= lh;
const explLines = wrapText(ln.noReceiptExplanation || '', fontBody, sz, W);
explLines.forEach(line => {
needSpace(lh);
pg.drawText(line, {x:M.left, y, size:sz, font:fontBody, color:black});
y -= lh;
});
}
y -= 6;
});
});
// Grand total
needSpace(lh * 2);
pg.drawLine({start:{x:M.left,y}, end:{x:M.left+W,y}, thickness:3, color:accent});
y -= lh;
const gtStr = `Total reimbursement claim: ${baseCur} ${fmtAmt(state._grandTotal)}`;
const gtW = fontBold.widthOfTextAtSize(gtStr, sz + 2);
pg.drawText(gtStr, {x: M.left + W - gtW, y, size: sz+2, font: fontBold, color: accent});
// ---- RECEIPT PAGES ----
const formPageCount = pages.length;
const receiptPageMap = {}; // key -> page number
for (const item of state.items) {
for (const ln of item.lines) {
if (!ln.hasReceipt) continue;
for (let ri = 0; ri < ln.receipts.length; ri++) {
const r = ln.receipts[ri];
const key = `${ln.id}-${ri}`;
const startPage = pages.length + 1; // 1-based
if (r.type === 'application/pdf') {
try {
const srcDoc = await PDFDocument.load(r.data);
const srcPages = await doc.copyPages(srcDoc, srcDoc.getPageIndices());
srcPages.forEach(p => { doc.addPage(p); pages.push(p); });
} catch (e) {
// Failed to merge PDF — add error page
const ep = doc.addPage([pageW, pageH]);
pages.push(ep);
ep.drawText(`Could not embed: ${r.name}`, {x:M.left, y:pageH-M.top, size:sz, font:fontBody, color:rgb(0.8,0,0)});
ep.drawText(String(e.message || e), {x:M.left, y:pageH-M.top-lh, size:szSm, font:fontBody, color:gray});
}
} else {
// Image
const rp = doc.addPage([pageW, pageH]);
pages.push(rp);
try {
let img;
if (r.type === 'image/png') img = await doc.embedPng(r.data);
else img = await doc.embedJpg(r.data);
const maxW2 = pageW - M.left - M.right;
const maxH2 = pageH - M.top - M.bottom;
const sc = Math.min(maxW2 / img.width, maxH2 / img.height, 1);
const iw = img.width * sc, ih = img.height * sc;
const ix = M.left + (maxW2 - iw) / 2;
const iy = M.bottom + (maxH2 - ih) / 2;
rp.drawImage(img, {x:ix, y:iy, width:iw, height:ih});
} catch (e) {
rp.drawText(`Could not embed: ${r.name}`, {x:M.left, y:pageH-M.top, size:sz, font:fontBody, color:rgb(0.8,0,0)});
}
}
receiptPageMap[key] = startPage;
}
}
}
// ---- FILL IN RECEIPT REFERENCES ----
receiptRefs.forEach(ref => {
const pageNum = receiptPageMap[ref.key];
if (pageNum != null) {
const pg2 = pages[ref.pageIdx];
pg2.drawText(`See page ${pageNum} for receipt`, {x:ref.x, y:ref.y, size:szSm, font:fontBody, color:gray});
}
});
// ---- FOOTERS ----
const totalPages = pages.length;
const now = new Date();
const printed = `${now.getFullYear()}-${String(now.getMonth()+1).padStart(2,'0')}-${String(now.getDate()).padStart(2,'0')} ${String(now.getHours()).padStart(2,'0')}:${String(now.getMinutes()).padStart(2,'0')}`;
pages.forEach((p, i) => {
const fy = M.bottom - 30;
p.drawLine({start:{x:M.left, y:fy+18}, end:{x:M.left+W, y:fy+18}, thickness:0.5, color:lineCol});
p.drawText('Reimbursement form', {x:M.left, y:fy, size:szSm-1, font:fontBody, color:gray});
p.drawText(state.staff, {x:M.left, y:fy-lh+2, size:szSm-1, font:fontBody, color:gray});
const pgStr = `Page ${i+1}/${totalPages}`;
const pgW2 = fontBody.widthOfTextAtSize(pgStr, szSm-1);
p.drawText(pgStr, {x:M.left+W-pgW2, y:fy, size:szSm-1, font:fontBody, color:gray});
const prStr = `Printed: ${printed}`;
const prW = fontBody.widthOfTextAtSize(prStr, szSm-1);
p.drawText(prStr, {x:M.left+W-prW, y:fy-lh+2, size:szSm-1, font:fontBody, color:gray});
});
// ---- SAVE ----
const pdfBytes = await doc.save();
const blob = new Blob([pdfBytes], {type:'application/pdf'});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
const fname = `reimbursement_${state.staff.replace(/\s+/g,'_')}_${state.periodFrom}_${state.periodTo}.pdf`;
a.download = fname;
a.click();
URL.revokeObjectURL(url);
}
// PDF helpers
function parseHex(hex) {
const h = hex.replace('#','');
return PDFLib.rgb(parseInt(h.slice(0,2),16)/255, parseInt(h.slice(2,4),16)/255, parseInt(h.slice(4,6),16)/255);
}
function wrapText(text, font, size, maxW) {
const words = text.split(/\s+/);
const lines = [];
let cur = '';
words.forEach(w => {
const test = cur ? cur + ' ' + w : w;
if (font.widthOfTextAtSize(test, size) > maxW) {
if (cur) lines.push(cur);
cur = w;
} else {
cur = test;
}
});
if (cur) lines.push(cur);
return lines.length ? lines : [''];
}
function truncate(text, font, size, maxW) {
if (!text) return '';
if (font.widthOfTextAtSize(text, size) <= maxW) return text;
let t = text;
while (t.length > 1 && font.widthOfTextAtSize(t + '…', size) > maxW) t = t.slice(0, -1);
return t + '…';
}
async function loadLogo(doc) {
for (const ext of ['png','jpg']) {
try {
const res = await fetch(`assets/logo.${ext}`);
if (!res.ok) continue;
const buf = await res.arrayBuffer();
return ext === 'png' ? await doc.embedPng(buf) : await doc.embedJpg(buf);
} catch(e) { continue; }
}
return null;
}
// ========== GENERATE HANDLER ==========
async function onGenerate() {
const valBox = $('#val-box');
valBox.innerHTML = '';
const errs = validate();
if (errs.length) {
const box = el('div', {className:'val-summary'});
box.innerHTML = '<strong>Please fix the following:</strong><br>' + errs.join('<br>');
valBox.appendChild(box);
valBox.scrollIntoView({behavior:'smooth'});
return;
}
const btn = $('#gen-btn');
btn.disabled = true;
btn.textContent = 'Generating PDF…';
try {
await generatePDF();
} catch (e) {
alert('Error generating PDF: ' + e.message);
console.error(e);
}
btn.disabled = false;
btn.textContent = 'Generate Reimbursement Form';
}
// ========== INIT ==========
async function init() {
try {
await loadConfig();
state.baseCurrency = CFG['currency-base'];
state.items.push(newItem());
render();
} catch (e) {
$('#app').innerHTML = `<div class="wrap"><p style="color:var(--err)">Failed to load: ${e.message}</p></div>`;
console.error(e);
}
}
init();
})();
</script>
</body>
</html>