mirror of
https://github.com/kbenestad/reimburse.git
synced 2026-06-18 08:04:31 +00:00
Adds a footer bar at the bottom of the page with copyright, a link to docs.benestad.net/invoice, a repo link, and an About link that opens a modal. Modal title, Markdown content, and button label are configurable via config.yml (about-title, about-content, about-button). https://claude.ai/code/session_01MNy1ymwx9URLgXSgHc9W3T
1347 lines
58 KiB
HTML
1347 lines
58 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; }
|
||
.btn-save { background: #2e7d32; color: #fff; font-size: 15px; padding: 12px 32px; border-radius: 6px; }
|
||
.btn-save:hover { opacity: .9; }
|
||
|
||
/* 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; top: calc(100% + 6px); left: 0; background: #333; color: #fff; padding: 6px 10px; border-radius: 4px; font-size: 11px; line-height: 1.45; white-space: normal; max-width: 280px; z-index: 100; 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; }
|
||
}
|
||
|
||
/* App footer */
|
||
.app-footer { text-align: center; padding: 18px 16px; font-size: 12px; color: var(--muted); }
|
||
.app-footer a { color: var(--muted); text-decoration: none; }
|
||
.app-footer a:hover { color: var(--accent); text-decoration: underline; }
|
||
.app-footer .sep { margin: 0 6px; opacity: .5; }
|
||
|
||
/* About modal prose */
|
||
.about-body h1,.about-body h2,.about-body h3 { font-size: 14px; font-weight: 700; margin: 12px 0 4px; color: var(--accent); }
|
||
.about-body p { margin: 0 0 10px; }
|
||
.about-body ul { margin: 0 0 10px 18px; }
|
||
.about-body li { margin-bottom: 3px; }
|
||
.about-body a { color: var(--accent); }
|
||
.about-body strong { font-weight: 700; }
|
||
.about-body em { font-style: italic; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div id="app"><p class="loading">Loading configuration…</p></div>
|
||
<footer class="app-footer">
|
||
<span>© 2026 Kristian Benestad</span>
|
||
<span class="sep">•</span>
|
||
<a href="https://docs.benestad.net/invoice" target="_blank" rel="noopener">docs.benestad.net</a>
|
||
<span class="sep">•</span>
|
||
<a href="https://github.com/kbenestad/reimburse" target="_blank" rel="noopener">kbenestad/reimburse</a>
|
||
<span class="sep">•</span>
|
||
<a href="#" id="about-link">About</a>
|
||
</footer>
|
||
|
||
<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 showConfirmModal(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'}});
|
||
body.innerHTML = msg;
|
||
const footer = el('div', {style:{display:'flex',justifyContent:'flex-end',gap:'10px'}});
|
||
const cancelBtn = el('button', {className:'btn btn-add', style:{padding:'8px 18px',fontSize:'13px'}}, 'Cancel');
|
||
const confirmBtn = el('button', {className:'btn btn-gen', style:{width:'auto',padding:'8px 24px',marginTop:'0'}}, 'Yes, start new form');
|
||
cancelBtn.addEventListener('click', () => { overlay.remove(); resolve(false); });
|
||
confirmBtn.addEventListener('click', () => { overlay.remove(); resolve(true); });
|
||
footer.append(cancelBtn, confirmBtn);
|
||
box.append(hdr, body, footer);
|
||
overlay.appendChild(box);
|
||
document.body.appendChild(overlay);
|
||
cancelBtn.focus();
|
||
});
|
||
}
|
||
|
||
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'}});
|
||
body.innerHTML = 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();
|
||
});
|
||
}
|
||
|
||
function mdToHtml(md) {
|
||
if (!md) return '';
|
||
let html = md
|
||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||
.replace(/^#{3}\s+(.+)$/gm, '<h3>$1</h3>')
|
||
.replace(/^#{2}\s+(.+)$/gm, '<h2>$1</h2>')
|
||
.replace(/^#{1}\s+(.+)$/gm, '<h1>$1</h1>')
|
||
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||
.replace(/\*(.+?)\*/g, '<em>$1</em>')
|
||
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>');
|
||
// Lists
|
||
html = html.replace(/((?:^- .+\n?)+)/gm, m => '<ul>' + m.replace(/^- (.+)$/gm, '<li>$1</li>') + '</ul>');
|
||
// Paragraphs (blocks not already wrapped in a tag)
|
||
html = html.split(/\n{2,}/).map(b => b.trim()).filter(Boolean).map(b => /^<[hul]/.test(b) ? b : `<p>${b.replace(/\n/g, '<br>')}</p>`).join('\n');
|
||
return html;
|
||
}
|
||
|
||
function showAboutModal() {
|
||
const title = (CFG && CFG['about-title']) || 'About';
|
||
const content = (CFG && CFG['about-content']) || '';
|
||
const btnLabel = (CFG && CFG['about-button']) || 'Close';
|
||
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:'480px',width:'90%',maxHeight:'80vh',overflowY:'auto',boxShadow:'0 8px 32px rgba(0,0,0,.25)'}});
|
||
const hdr = el('div', {style:{marginBottom:'16px'}});
|
||
hdr.appendChild(el('strong', {style:{fontSize:'16px',color:'var(--accent)'}}, title));
|
||
const body = el('div', {className:'about-body', style:{fontSize:'13px',lineHeight:'1.65',color:'var(--text)',marginBottom:'20px'}});
|
||
body.innerHTML = mdToHtml(content);
|
||
const foot = el('div', {style:{display:'flex',justifyContent:'flex-end'}});
|
||
const closeBtn = el('button', {className:'btn btn-gen', style:{width:'auto',padding:'8px 28px',marginTop:'0'}}, btnLabel);
|
||
closeBtn.addEventListener('click', () => overlay.remove());
|
||
overlay.addEventListener('click', e => { if (e.target === overlay) overlay.remove(); });
|
||
foot.appendChild(closeBtn);
|
||
box.append(hdr, body, foot);
|
||
overlay.appendChild(box);
|
||
document.body.appendChild(overlay);
|
||
closeBtn.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: state.periodFrom || '', description: '', currency: state.baseCurrency,
|
||
fxRate: '1.00000', vendor: '', hasReceipt: true, receipts: [],
|
||
noReceiptExplanation: '', amount: '', account: '', customCurrency: false,
|
||
programs: [{ program: '', percent: '', programOther: '' }]
|
||
};
|
||
}
|
||
|
||
// ========== PERSISTENCE ==========
|
||
let db = null;
|
||
let autoSaveTimer = null;
|
||
|
||
async function initDB() {
|
||
return new Promise(resolve => {
|
||
if (!window.indexedDB) { resolve(false); return; }
|
||
const req = indexedDB.open('reimb-db', 1);
|
||
req.onupgradeneeded = e => e.target.result.createObjectStore('receipts');
|
||
req.onsuccess = e => { db = e.target.result; resolve(true); };
|
||
req.onerror = () => resolve(false);
|
||
});
|
||
}
|
||
|
||
function dbOp(mode, fn) {
|
||
return new Promise(resolve => {
|
||
if (!db) { resolve(undefined); return; }
|
||
const tx = db.transaction('receipts', mode);
|
||
const req = fn(tx.objectStore('receipts'));
|
||
req.onsuccess = () => resolve(req.result);
|
||
req.onerror = () => resolve(undefined);
|
||
});
|
||
}
|
||
const dbPut = (k, v) => dbOp('readwrite', s => s.put(v, k));
|
||
const dbGet = k => dbOp('readonly', s => s.get(k));
|
||
const dbDel = k => dbOp('readwrite', s => s.delete(k));
|
||
const dbAllKeys = () => dbOp('readonly', s => s.getAllKeys());
|
||
|
||
async function saveState() {
|
||
const serial = {
|
||
staff: state.staff, periodFrom: state.periodFrom, periodTo: state.periodTo,
|
||
baseCurrency: state.baseCurrency, fxRateMemory: state.fxRateMemory,
|
||
items: state.items.map(item => ({
|
||
id: item.id, name: item.name,
|
||
lines: item.lines.map(ln => ({
|
||
id: ln.id, date: ln.date, description: ln.description,
|
||
currency: ln.currency, fxRate: ln.fxRate, vendor: ln.vendor,
|
||
hasReceipt: ln.hasReceipt, noReceiptExplanation: ln.noReceiptExplanation,
|
||
amount: ln.amount, account: ln.account, customCurrency: ln.customCurrency || false,
|
||
programs: ln.programs,
|
||
receipts: ln.receipts.map(r => ({id: r.id, name: r.name, type: r.type}))
|
||
}))
|
||
}))
|
||
};
|
||
try { localStorage.setItem('reimb-state', JSON.stringify(serial)); } catch(e) {}
|
||
if (!db) return;
|
||
const kept = new Set();
|
||
for (const item of state.items)
|
||
for (const ln of item.lines)
|
||
for (const r of ln.receipts)
|
||
if (r.id && r.data) { kept.add(r.id); await dbPut(r.id, {name:r.name, type:r.type, data:r.data}); }
|
||
const all = await dbAllKeys();
|
||
if (Array.isArray(all)) for (const k of all) if (!kept.has(k)) await dbDel(k);
|
||
}
|
||
|
||
async function loadState() {
|
||
const raw = localStorage.getItem('reimb-state');
|
||
if (!raw) return false;
|
||
try {
|
||
const d = JSON.parse(raw);
|
||
if (d.staff) state.staff = d.staff;
|
||
if (d.periodFrom) state.periodFrom = d.periodFrom;
|
||
if (d.periodTo) state.periodTo = d.periodTo;
|
||
if (d.baseCurrency) state.baseCurrency = d.baseCurrency;
|
||
if (d.fxRateMemory) state.fxRateMemory = d.fxRateMemory;
|
||
if (d.items && d.items.length > 0) {
|
||
state.items = [];
|
||
for (const id of d.items) {
|
||
const item = { id: id.id, name: id.name, lines: [], _subtotal: 0 };
|
||
for (const ld of (id.lines || [])) {
|
||
const ln = {
|
||
id: ld.id, date: ld.date, description: ld.description,
|
||
currency: ld.currency, fxRate: ld.fxRate, vendor: ld.vendor,
|
||
hasReceipt: ld.hasReceipt, receipts: [],
|
||
noReceiptExplanation: ld.noReceiptExplanation,
|
||
amount: ld.amount, account: ld.account,
|
||
customCurrency: ld.customCurrency || false,
|
||
programs: ld.programs || [{ program: '', percent: '', programOther: '' }]
|
||
};
|
||
for (const rm of (ld.receipts || [])) {
|
||
if (rm.id && db) {
|
||
const rd = await dbGet(rm.id);
|
||
if (rd) ln.receipts.push({ id: rm.id, name: rd.name, type: rd.type, data: rd.data });
|
||
}
|
||
}
|
||
item.lines.push(ln);
|
||
}
|
||
state.items.push(item);
|
||
}
|
||
}
|
||
return true;
|
||
} catch(e) { console.error('State load failed:', e); return false; }
|
||
}
|
||
|
||
function scheduleAutoSave() {
|
||
clearTimeout(autoSaveTimer);
|
||
autoSaveTimer = setTimeout(saveState, 1000);
|
||
}
|
||
|
||
// ========== 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 HELPERS ==========
|
||
function getCurrencyName(code) {
|
||
const c = (CFG.currencies || []).find(c => c.code === code);
|
||
return c ? c.name : code;
|
||
}
|
||
function buildFxTip(code, base) {
|
||
if (!code || code === base || code.length < 3) return '';
|
||
const fn = getCurrencyName(code), bn = getCurrencyName(base);
|
||
return `Enter the exchange rate expressed as the amount of ${fn} you pay for 1 ${bn}. E.g., XX.XX ${fn} per 1 ${bn} if your expense was in ${fn} and you submit your reimbursement form in ${bn}.`;
|
||
}
|
||
|
||
// ========== 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 newFormBtn = el('button', {type:'button', className:'btn btn-save', style:{padding:'7px 14px',fontSize:'13px'}, onClick: onNewForm}, 'New Form');
|
||
|
||
const fromInput = el('input', {type:'date'});
|
||
fromInput.value = state.periodFrom;
|
||
fromInput.addEventListener('change', () => { state.periodFrom = fromInput.value; });
|
||
const toInput = el('input', {type:'date'});
|
||
toInput.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'), el('div', {style:{display:'flex',gap:'8px',alignItems:'center'}}, [staffInput, newFormBtn])]),
|
||
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)}`)
|
||
])
|
||
]));
|
||
|
||
// Action buttons
|
||
const actionRow = el('div', {style:{display:'flex', gap:'12px', marginTop:'16px'}});
|
||
actionRow.append(
|
||
el('button', {className:'btn btn-save', onClick: onSave}, 'Save Reimbursement Form'),
|
||
el('button', {className:'btn btn-gen', id:'gen-btn', style:{flex:'1', marginTop:'0', width:'auto'}, onClick: onGenerate}, 'Generate Reimbursement Form')
|
||
);
|
||
wrap.appendChild(actionRow);
|
||
|
||
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', style:{width:'150px'}});
|
||
if (ln.date) dateIn.value = ln.date;
|
||
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; });
|
||
|
||
async function showFxModal(code) {
|
||
const bn = getCurrencyName(baseCur);
|
||
const isOther = !code || code === 'Other';
|
||
const tmpl = isOther
|
||
? (CFG['fx-rate-message-other'] || 'You have selected an expense in a currency different from the one in which you are submitting your claim. Enter the three letter currency code first, and then enter the exchange rate as amount of the foreign currency you pay for 1 {base}.')
|
||
: (CFG['fx-rate-message'] || 'You have selected an expense in a currency different from the one in which you are submitting your claim. Please enter the exchange rate as amount of {foreign} you pay for 1 {base}.');
|
||
const fn = isOther ? 'the foreign currency' : getCurrencyName(code);
|
||
await showWarningModal(tmpl.replace(/\{foreign\}/g, fn).replace(/\{base\}/g, bn));
|
||
}
|
||
|
||
function applyFxCurrency(code) {
|
||
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 currenciesWithOther = [...currencies, {code: 'Other', name: 'Enter ISO code'}];
|
||
const curDD = makeCDD(currenciesWithOther, ln.customCurrency ? 'Other' : ln.currency, async code => {
|
||
if (code === 'Other') {
|
||
ln.customCurrency = true;
|
||
ln.currency = '';
|
||
curDD.style.display = 'none';
|
||
otherCurWrap.style.display = 'flex';
|
||
ln.fxRate = ''; fxIn.value = ''; fxIn.readOnly = false;
|
||
recalc(); if (progAreaEl) progAreaEl._refresh();
|
||
await showFxModal('Other');
|
||
otherCurIn.focus();
|
||
} else {
|
||
ln.customCurrency = false;
|
||
ln.currency = code;
|
||
applyFxCurrency(code);
|
||
if (code !== baseCur) await showFxModal(code);
|
||
}
|
||
});
|
||
if (ln.customCurrency) curDD.style.display = 'none';
|
||
|
||
const otherCurIn = el('input', {type:'text', maxlength:'3', placeholder:'e.g. THB',
|
||
style:{width:'70px', textTransform:'uppercase', letterSpacing:'1px', fontFamily:'inherit'}});
|
||
if (ln.customCurrency && ln.currency) otherCurIn.value = ln.currency;
|
||
otherCurIn.addEventListener('input', () => {
|
||
const val = otherCurIn.value.toUpperCase().replace(/[^A-Z]/g, '').slice(0, 3);
|
||
otherCurIn.value = val;
|
||
ln.currency = val;
|
||
if (val.length === 3) {
|
||
applyFxCurrency(val);
|
||
} else {
|
||
ln.fxRate = ''; fxIn.value = ''; fxIn.readOnly = false;
|
||
recalc(); if (progAreaEl) progAreaEl._refresh();
|
||
}
|
||
});
|
||
|
||
const cancelOtherBtn = el('button', {type:'button', className:'btn btn-rm', style:{padding:'2px 6px', fontSize:'12px'}}, '×');
|
||
cancelOtherBtn.addEventListener('click', () => {
|
||
ln.customCurrency = false;
|
||
ln.currency = baseCur;
|
||
otherCurWrap.style.display = 'none';
|
||
curDD.style.display = '';
|
||
curDD._setValue(baseCur);
|
||
applyFxCurrency(baseCur);
|
||
});
|
||
|
||
const otherCurWrap = el('div', {style:{gap:'4px', alignItems:'center', display: ln.customCurrency ? 'flex' : 'none'}});
|
||
otherCurWrap.append(otherCurIn, cancelOtherBtn);
|
||
|
||
const curWrap = el('div');
|
||
curWrap.append(curDD, otherCurWrap);
|
||
|
||
const fxIn = el('input', {type:'text', style:{width:'120px', textAlign:'right'}, placeholder:'0.00000'});
|
||
fxIn.value = ln.fxRate;
|
||
fxIn.readOnly = !ln.customCurrency && ln.currency === 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'), curWrap]),
|
||
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));
|
||
saveState();
|
||
}}, '✕')
|
||
]));
|
||
});
|
||
// 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({ id: uid(), name: f.name, type: f.type, data: await f.arrayBuffer() });
|
||
}
|
||
area.replaceWith(buildReceiptArea(ln));
|
||
saveState();
|
||
});
|
||
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(checkReceipts = true) {
|
||
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 (checkReceipts && 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';
|
||
}
|
||
|
||
// ========== NEW FORM HANDLER ==========
|
||
async function onNewForm() {
|
||
const confirmed = await showConfirmModal('This will clear all current form data and start a fresh form.<br><br>Are you sure you want to continue?');
|
||
if (!confirmed) return;
|
||
try { localStorage.removeItem('reimb-state'); } catch(e) {}
|
||
if (db) {
|
||
const keys = await dbAllKeys();
|
||
if (Array.isArray(keys)) for (const k of keys) await dbDel(k);
|
||
}
|
||
const p = defaultPeriod();
|
||
state.periodFrom = p.from;
|
||
state.periodTo = p.to;
|
||
state.baseCurrency = CFG['currency-base'];
|
||
state.fxRateMemory = {};
|
||
state.items = [newItem()];
|
||
state._grandTotal = 0;
|
||
render();
|
||
}
|
||
|
||
// ========== SAVE HANDLER ==========
|
||
async function onSave() {
|
||
const valBox = $('#val-box');
|
||
valBox.innerHTML = '';
|
||
const errs = validate(false);
|
||
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;
|
||
}
|
||
await saveState();
|
||
await showWarningModal('The information in this reimbursement form is now saved. It will be visible next time you visit this app in <strong>the same browser on the same device.</strong> If you delete your browser history/cache, the information will be lost.');
|
||
}
|
||
|
||
// ========== INIT ==========
|
||
async function init() {
|
||
try {
|
||
await loadConfig();
|
||
await initDB();
|
||
state.baseCurrency = CFG['currency-base'];
|
||
const p = defaultPeriod();
|
||
state.periodFrom = p.from;
|
||
state.periodTo = p.to;
|
||
const hasState = await loadState();
|
||
if (!hasState) state.items.push(newItem());
|
||
render();
|
||
document.addEventListener('input', scheduleAutoSave);
|
||
document.addEventListener('change', scheduleAutoSave);
|
||
const canSaveReceipts = !!db;
|
||
let entryMsg = 'This reimbursement form saves your input in the browser. As long as you do not delete your browser history/cache, the information is preserved and shown to you on the next visit in <strong>this</strong> browser.';
|
||
if (!canSaveReceipts) entryMsg += '<br><br>Please note that PDFs and images cannot be saved in the browser. Please do not attach receipts before you are ready to print and submit the reimbursement form.';
|
||
await showWarningModal(entryMsg);
|
||
} catch (e) {
|
||
$('#app').innerHTML = `<div class="wrap"><p style="color:var(--err)">Failed to load: ${e.message}</p></div>`;
|
||
console.error(e);
|
||
}
|
||
}
|
||
|
||
const aboutLink = document.getElementById('about-link');
|
||
if (aboutLink) aboutLink.addEventListener('click', e => { e.preventDefault(); showAboutModal(); });
|
||
|
||
init();
|
||
})();
|
||
</script>
|
||
</body>
|
||
</html>
|