mirror of
https://github.com/kbenestad/reimburse.git
synced 2026-06-18 08:04:31 +00:00
When a user enters a date outside the selected period, a modal warning appears explaining they can continue but the date will be flagged. The date input gets an amber border/background as a persistent visual cue. The PDF marks out-of-period dates in orange with a trailing (!) marker. https://claude.ai/code/session_01MbwfxnjLA9KdFTrfzB55HM
1032 lines
43 KiB
HTML
1032 lines
43 KiB
HTML
<!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});
|
||
pg.drawText('FX rate', {x:M.left+c4, 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});
|
||
pg.drawText('Amount', {x:M.left+c4, 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>
|