mirror of
https://github.com/kbenestad/reimburse.git
synced 2026-06-18 08:04:31 +00:00
- Add favicon-16x16.png, favicon-32x32.png (renamed from favicon-32.png) - Add favicon-48x48.png, favicon.ico (16+32 embedded) - Add icon-192.png (downscaled from 512), icon-512.png - Add site.webmanifest (theme #2f6fed, standard icon names) - Update <head> links: SVG → 32 → 16 → ICO fallback chain, manifest link, theme-color meta https://claude.ai/code/session_01JyuActqTJG5tuRQNLmT7fZ
1779 lines
84 KiB
HTML
1779 lines
84 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<meta name="format-detection" content="telephone=no">
|
||
<title>Reimbursement</title>
|
||
<link rel="icon" href="assets/favicon.svg" type="image/svg+xml">
|
||
<link rel="icon" href="assets/favicon-32x32.png" sizes="32x32" type="image/png">
|
||
<link rel="icon" href="assets/favicon-16x16.png" sizes="16x16" type="image/png">
|
||
<link rel="shortcut icon" href="assets/favicon.ico">
|
||
<link rel="apple-touch-icon" href="assets/apple-touch-icon.png">
|
||
<link rel="manifest" href="assets/site.webmanifest">
|
||
<meta name="theme-color" content="#2f6fed">
|
||
<script src="https://unpkg.com/pdf-lib@1.17.1/dist/pdf-lib.min.js"></script>
|
||
<script src="https://unpkg.com/js-yaml@4.1.0/dist/js-yaml.min.js"></script>
|
||
<script>
|
||
(function(){var t=localStorage.getItem('reimb-theme');if(t)document.documentElement.setAttribute('data-theme',t);})();
|
||
</script>
|
||
<style>
|
||
/* ── Fonts ────────────────────────────────────────────────────────────────── */
|
||
@font-face {
|
||
font-family:"Schibsted Grotesk";font-style:normal;font-weight:400 800;font-display:swap;
|
||
src:local("Schibsted Grotesk"),url("https://fonts.bunny.net/schibsted-grotesk/files/schibsted-grotesk-latin-400-normal.woff2") format("woff2");
|
||
}
|
||
@font-face {
|
||
font-family:"JetBrains Mono";font-style:normal;font-weight:400 600;font-display:swap;
|
||
src:local("JetBrains Mono"),url("https://fonts.bunny.net/jetbrains-mono/files/jetbrains-mono-latin-500-normal.woff2") format("woff2");
|
||
}
|
||
|
||
/* ── Tokens (light) ───────────────────────────────────────────────────────── */
|
||
:root {
|
||
--font-sans:"Schibsted Grotesk",-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,system-ui,sans-serif;
|
||
--font-mono:"JetBrains Mono",ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;
|
||
--font-scale:1;
|
||
--fs-base:calc(15px * var(--font-scale));
|
||
--fs-input:calc(15px * var(--font-scale));
|
||
--fs-label:calc(12px * var(--font-scale));
|
||
--fs-title:calc(13px * var(--font-scale));
|
||
--fs-small:calc(12.5px * var(--font-scale));
|
||
--fs-h1:calc(22px * var(--font-scale));
|
||
|
||
--accent:#2F6FED;
|
||
--accent-hover:#1F57CF;
|
||
--accent-soft:#EEF3FE;
|
||
--accent-border:#C7D9FB;
|
||
--on-accent:#FFFFFF;
|
||
|
||
--bg:#F4F6F9;
|
||
--surface:#FFFFFF;
|
||
--surface-2:#F8F9FB;
|
||
--surface-3:#F1F3F6;
|
||
--border:#E3E7EE;
|
||
--border-strong:#D3D9E2;
|
||
--text:#14181E;
|
||
--text-soft:#3A434F;
|
||
--text-muted:#5F6975;
|
||
--placeholder:#9AA3AF;
|
||
|
||
--danger:#D64545;--danger-soft:#FBEAEA;--danger-border:#F0C9C9;
|
||
--warning:#C9851F;--warning-soft:#FBF1DD;--warning-border:#EED9AD;
|
||
--success:#1F9D5F;--success-soft:#E2F3EA;--success-border:#BFE3CF;
|
||
--info:#2F6FED;--info-soft:#EEF3FE;--info-border:#C7D9FB;
|
||
|
||
--radius:8px;
|
||
--radius-sm:6px;
|
||
--radius-pill:999px;
|
||
--shadow-sm:0 1px 2px rgba(20,24,30,.05);
|
||
--shadow:0 6px 22px rgba(20,24,30,.08);
|
||
--ring:0 0 0 3px rgba(47,111,237,.20);
|
||
color-scheme:light;
|
||
}
|
||
|
||
/* ── Dark mode ────────────────────────────────────────────────────────────── */
|
||
@media (prefers-color-scheme:dark){
|
||
:root:not([data-theme="light"]){
|
||
--accent:#5685E9;--accent-hover:#6C98EF;--accent-soft:#16233F;--accent-border:#21386A;
|
||
--bg:#0D1117;--surface:#161B22;--surface-2:#1C232C;--surface-3:#1C232C;
|
||
--border:#232A33;--border-strong:#2D3641;
|
||
--text:#EEF1F5;--text-soft:#C2CAD3;--text-muted:#8B95A1;--placeholder:#6F7986;
|
||
--danger:#E06464;--danger-soft:#341819;--danger-border:#5A2A2A;
|
||
--warning:#D99A3A;--warning-soft:#33270F;--warning-border:#574017;
|
||
--success:#3BB97A;--success-soft:#13301F;--success-border:#1F4D33;
|
||
--info:#88ABF2;--info-soft:#16233F;--info-border:#21386A;
|
||
--shadow-sm:0 1px 2px rgba(0,0,0,.4);--shadow:0 8px 28px rgba(0,0,0,.5);
|
||
--ring:0 0 0 3px rgba(86,133,233,.32);
|
||
color-scheme:dark;
|
||
}
|
||
}
|
||
:root[data-theme="dark"]{
|
||
--accent:#5685E9;--accent-hover:#6C98EF;--accent-soft:#16233F;--accent-border:#21386A;
|
||
--bg:#0D1117;--surface:#161B22;--surface-2:#1C232C;--surface-3:#1C232C;
|
||
--border:#232A33;--border-strong:#2D3641;
|
||
--text:#EEF1F5;--text-soft:#C2CAD3;--text-muted:#8B95A1;--placeholder:#6F7986;
|
||
--danger:#E06464;--danger-soft:#341819;--danger-border:#5A2A2A;
|
||
--warning:#D99A3A;--warning-soft:#33270F;--warning-border:#574017;
|
||
--success:#3BB97A;--success-soft:#13301F;--success-border:#1F4D33;
|
||
--info:#88ABF2;--info-soft:#16233F;--info-border:#21386A;
|
||
--shadow-sm:0 1px 2px rgba(0,0,0,.4);--shadow:0 8px 28px rgba(0,0,0,.5);
|
||
--ring:0 0 0 3px rgba(86,133,233,.32);
|
||
color-scheme:dark;
|
||
}
|
||
|
||
/* ── Base ─────────────────────────────────────────────────────────────────── */
|
||
*{box-sizing:border-box;margin:0;padding:0;}
|
||
body{
|
||
font-family:var(--font-sans);font-size:var(--fs-base);line-height:1.55;
|
||
color:var(--text);background:var(--bg);
|
||
-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;
|
||
letter-spacing:-0.006em;
|
||
}
|
||
.kb-mono{font-family:var(--font-mono);font-variant-numeric:tabular-nums;}
|
||
|
||
/* ── Page shell ───────────────────────────────────────────────────────────── */
|
||
.kb-wrap{max-width:960px;margin:0 auto;padding:24px 20px 56px;}
|
||
|
||
/* ── Toolbar ──────────────────────────────────────────────────────────────── */
|
||
.kb-toolbar{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:16px;}
|
||
.kb-toolbar .spacer{flex:1;}
|
||
.kb-seg{
|
||
display:inline-flex;align-items:center;gap:2px;
|
||
background:var(--surface);border:1px solid var(--border);
|
||
border-radius:var(--radius-sm);padding:3px;
|
||
}
|
||
.kb-seg button{
|
||
font:600 var(--fs-small)/1 var(--font-sans);color:var(--text-muted);
|
||
background:transparent;border:0;white-space:nowrap;
|
||
padding:6px 11px;border-radius:4px;cursor:pointer;
|
||
}
|
||
.kb-seg button.is-active{background:var(--accent-soft);color:var(--accent);}
|
||
.kb-seg button:hover:not(.is-active){color:var(--text);}
|
||
.kb-iconbtn{
|
||
display:inline-grid;place-items:center;width:34px;height:34px;
|
||
background:var(--surface);border:1px solid var(--border);
|
||
border-radius:var(--radius-sm);color:var(--text-muted);cursor:pointer;
|
||
}
|
||
.kb-iconbtn:hover{color:var(--accent);border-color:var(--accent-border);}
|
||
|
||
/* ── Document header ──────────────────────────────────────────────────────── */
|
||
.kb-header{
|
||
display:flex;justify-content:space-between;align-items:flex-start;
|
||
gap:24px;padding-bottom:20px;margin-bottom:20px;
|
||
border-bottom:1px solid var(--border);
|
||
}
|
||
.kb-brand{display:flex;align-items:center;gap:14px;min-width:0;}
|
||
.kb-brand .logo{
|
||
height:46px;width:46px;flex:0 0 46px;border-radius:10px;
|
||
display:grid;place-items:center;overflow:hidden;
|
||
}
|
||
.kb-brand .logo img{width:100%;height:100%;object-fit:contain;}
|
||
.kb-brand .org{font-size:17px;font-weight:700;color:var(--text);letter-spacing:-0.01em;}
|
||
.kb-brand .org small{display:block;font-size:var(--fs-small);font-weight:500;color:var(--text-muted);letter-spacing:0;}
|
||
.kb-doctitle{text-align:right;}
|
||
.kb-doctitle h1{margin:0;font-size:var(--fs-h1);font-weight:700;letter-spacing:-0.01em;color:var(--text);}
|
||
.kb-doctitle .meta{margin-top:4px;font-size:var(--fs-small);color:var(--text-muted);font-family:var(--font-mono);}
|
||
|
||
/* ── Cards ────────────────────────────────────────────────────────────────── */
|
||
.kb-card{
|
||
background:var(--surface);border:1px solid var(--border);
|
||
border-radius:var(--radius);box-shadow:var(--shadow-sm);
|
||
padding:20px 22px;margin-bottom:16px;
|
||
}
|
||
.kb-card__title{
|
||
display:flex;align-items:center;gap:9px;
|
||
font-size:var(--fs-title);font-weight:700;letter-spacing:-0.005em;
|
||
color:var(--text-soft);margin:0 0 16px;
|
||
}
|
||
.kb-card__title::before{content:"";width:3px;height:14px;border-radius:2px;background:var(--accent);}
|
||
.kb-card__title .count{margin-left:auto;font-weight:500;color:var(--text-muted);font-size:var(--fs-small);}
|
||
|
||
/* ── Fields ───────────────────────────────────────────────────────────────── */
|
||
.kb-grid{display:grid;gap:14px 16px;}
|
||
.kb-grid.cols-2{grid-template-columns:1fr 1fr;}
|
||
.kb-grid.cols-3{grid-template-columns:1fr 1fr 1fr;}
|
||
.kb-grid.cols-4{grid-template-columns:1fr 1fr 1fr 1fr;}
|
||
.kb-field{display:flex;flex-direction:column;gap:5px;min-width:0;}
|
||
.kb-field.grow{flex:1;}
|
||
.kb-label{
|
||
font-size:var(--fs-label);font-weight:600;letter-spacing:0.03em;
|
||
text-transform:uppercase;color:var(--text-muted);
|
||
}
|
||
.kb-input,.kb-select,.kb-textarea{
|
||
width:100%;font:400 var(--fs-input)/1.4 var(--font-sans);
|
||
color:var(--text);background:var(--surface);
|
||
border:1px solid var(--border-strong);border-radius:var(--radius-sm);
|
||
padding:9px 11px;outline:none;transition:border-color .14s,box-shadow .14s;
|
||
}
|
||
.kb-input::placeholder,.kb-textarea::placeholder{color:var(--placeholder);}
|
||
.kb-input:focus,.kb-select:focus,.kb-textarea:focus{border-color:var(--accent);box-shadow:var(--ring);}
|
||
.kb-input:disabled,.kb-select:disabled,.kb-input[readonly]{background:var(--surface-3);color:var(--text-muted);cursor:not-allowed;}
|
||
.kb-input.num,.kb-mono-input{font-family:var(--font-mono);text-align:right;font-variant-numeric:tabular-nums;}
|
||
.kb-textarea{resize:vertical;min-height:52px;}
|
||
.kb-select{
|
||
appearance:none;
|
||
background-image:url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 16 16' fill='none' stroke='%235F6975' stroke-width='1.6'><path d='M4 6l4 4 4-4'/></svg>");
|
||
background-repeat:no-repeat;background-position:right 10px center;padding-right:32px;
|
||
}
|
||
.kb-input.is-error,.kb-select.is-error{border-color:var(--danger);}
|
||
.kb-input.is-warn,.kb-select.is-warn{border-color:var(--warning);background:var(--warning-soft);}
|
||
|
||
/* ── Buttons ──────────────────────────────────────────────────────────────── */
|
||
.kb-btn{
|
||
display:inline-flex;align-items:center;justify-content:center;gap:7px;
|
||
white-space:nowrap;font:600 var(--fs-input)/1 var(--font-sans);
|
||
padding:10px 16px;border-radius:var(--radius-sm);
|
||
border:1px solid transparent;cursor:pointer;
|
||
transition:background .14s,border-color .14s,color .14s;
|
||
}
|
||
.kb-btn svg{width:16px;height:16px;}
|
||
.kb-btn--primary{background:var(--accent);color:var(--on-accent);}
|
||
.kb-btn--primary:hover{background:var(--accent-hover);}
|
||
.kb-btn--primary:disabled{opacity:.5;cursor:not-allowed;}
|
||
.kb-btn--ghost{background:var(--surface);color:var(--text-soft);border-color:var(--border-strong);}
|
||
.kb-btn--ghost:hover{border-color:var(--accent);color:var(--accent);}
|
||
.kb-btn--soft{background:var(--accent-soft);color:var(--accent);}
|
||
.kb-btn--soft:hover{background:var(--accent);color:var(--on-accent);}
|
||
.kb-btn--dashed{background:transparent;color:var(--accent);border:1px dashed var(--accent-border);}
|
||
.kb-btn--dashed:hover{background:var(--accent-soft);border-color:var(--accent);}
|
||
.kb-btn--danger{background:transparent;color:var(--danger);padding:6px 10px;border:1px solid transparent;}
|
||
.kb-btn--danger:hover{background:var(--danger-soft);}
|
||
.kb-btn--lg{padding:13px 26px;}
|
||
.kb-btn--block{width:100%;}
|
||
|
||
/* ── Item blocks ──────────────────────────────────────────────────────────── */
|
||
.kb-block{
|
||
border:1px solid var(--border);border-radius:var(--radius);
|
||
background:var(--surface-2);padding:16px 18px;margin-bottom:14px;
|
||
}
|
||
.kb-block__head{
|
||
display:flex;align-items:center;gap:12px;margin-bottom:14px;flex-wrap:wrap;
|
||
}
|
||
.kb-block__tag{
|
||
font-size:var(--fs-label);font-weight:700;text-transform:uppercase;
|
||
letter-spacing:.04em;color:var(--accent);
|
||
}
|
||
.kb-block__name{flex:1;}
|
||
.kb-subtotal{font-family:var(--font-mono);font-weight:600;color:var(--text);font-variant-numeric:tabular-nums;white-space:nowrap;}
|
||
|
||
/* ── Line block ───────────────────────────────────────────────────────────── */
|
||
.kb-line{padding:14px 0;}
|
||
.kb-line+.kb-line{border-top:1px solid var(--border);}
|
||
.kb-line-section{display:grid;gap:12px 14px;margin-bottom:12px;}
|
||
.kb-line-section.row1{grid-template-columns:140px 1fr auto auto;}
|
||
.kb-line-section.row2{grid-template-columns:1fr auto auto;}
|
||
.kb-line-section.row3{grid-template-columns:1fr 1fr;}
|
||
|
||
/* ── Receipt area ─────────────────────────────────────────────────────────── */
|
||
.rb-receipt-area{margin-top:10px;}
|
||
.rb-receipt{
|
||
display:flex;align-items:center;gap:9px;padding:8px 12px;margin-bottom:6px;
|
||
background:var(--accent-soft);border:1px solid var(--accent-border);
|
||
border-radius:var(--radius-sm);font-size:var(--fs-small);color:var(--text-soft);
|
||
}
|
||
.rb-receipt svg{width:15px;height:15px;color:var(--accent);flex:0 0 15px;}
|
||
.rb-receipt .name{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
|
||
.rb-receipt .rm{background:none;border:none;cursor:pointer;color:var(--text-muted);padding:0 2px;font-size:14px;line-height:1;}
|
||
.rb-receipt .rm:hover{color:var(--danger);}
|
||
.rb-no-receipt{margin-top:8px;}
|
||
|
||
/* ── Totals ───────────────────────────────────────────────────────────────── */
|
||
.kb-totals .grand{
|
||
display:flex;justify-content:space-between;align-items:baseline;gap:16px;
|
||
padding-top:12px;border-top:1px solid var(--border-strong);
|
||
}
|
||
.kb-totals .grand .lab{font-weight:700;color:var(--text);font-size:var(--fs-base);}
|
||
.kb-totals .grand .val{
|
||
font-family:var(--font-mono);font-weight:700;font-size:calc(20px * var(--font-scale));
|
||
color:var(--accent);font-variant-numeric:tabular-nums;
|
||
}
|
||
|
||
/* ── Validation note ──────────────────────────────────────────────────────── */
|
||
.kb-note{
|
||
border-radius:var(--radius-sm);padding:12px 16px;margin-bottom:16px;
|
||
font-size:var(--fs-small);line-height:1.7;
|
||
display:flex;gap:10px;align-items:flex-start;
|
||
}
|
||
.kb-note svg{width:17px;height:17px;flex:0 0 17px;margin-top:1px;}
|
||
.kb-note--error{background:var(--danger-soft);border:1px solid var(--danger-border);color:var(--danger);}
|
||
.kb-note--warning{background:var(--warning-soft);border:1px solid var(--warning-border);color:var(--warning);}
|
||
.kb-note--success{background:var(--success-soft);border:1px solid var(--success-border);color:var(--success);}
|
||
.kb-note--info{background:var(--info-soft);border:1px solid var(--info-border);color:var(--info);}
|
||
|
||
/* ── Custom currency dropdown ─────────────────────────────────────────────── */
|
||
.cdd{position:relative;display:inline-block;}
|
||
.cdd-trigger{
|
||
padding:9px 32px 9px 11px;border:1px solid var(--border-strong);border-radius:var(--radius-sm);
|
||
background:var(--surface) url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 16 16' fill='none' stroke='%235F6975' stroke-width='1.6'><path d='M4 6l4 4 4-4'/></svg>") no-repeat right 10px center;
|
||
cursor:pointer;font:400 var(--fs-input)/1.4 var(--font-sans);color:var(--text);
|
||
text-align:left;width:100%;outline:none;transition:border-color .14s,box-shadow .14s;
|
||
}
|
||
.cdd-trigger:focus{border-color:var(--accent);box-shadow:var(--ring);}
|
||
.cdd-panel{
|
||
display:none;position:absolute;top:calc(100% + 2px);left:0;
|
||
background:var(--surface);border:1px solid var(--border);
|
||
border-radius:var(--radius);box-shadow:var(--shadow);
|
||
z-index:100;min-width:200px;max-height:260px;overflow-y:auto;
|
||
}
|
||
.cdd-panel.open{display:block;}
|
||
.cdd-opt{padding:8px 12px;cursor:pointer;}
|
||
.cdd-opt:hover{background:var(--surface-2);}
|
||
.cdd-opt .code{font-weight:600;font-size:var(--fs-input);color:var(--text);}
|
||
.cdd-opt .name{font-size:var(--fs-small);color:var(--text-muted);}
|
||
.cdd-opt+.cdd-opt{border-top:1px solid var(--border);}
|
||
|
||
/* ── Modal overlay ────────────────────────────────────────────────────────── */
|
||
.kb-overlay{
|
||
position:fixed;inset:0;background:rgba(14,18,26,.55);
|
||
z-index:9999;display:flex;align-items:center;justify-content:center;padding:20px;
|
||
}
|
||
.kb-modal{
|
||
background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);
|
||
box-shadow:var(--shadow);padding:24px 28px;
|
||
max-width:420px;width:100%;max-height:80vh;overflow-y:auto;
|
||
}
|
||
.kb-modal h2{font-size:var(--fs-title);font-weight:700;color:var(--text);margin-bottom:12px;}
|
||
.kb-modal p,.kb-modal .body{font-size:var(--fs-small);line-height:1.65;color:var(--text-soft);margin-bottom:20px;}
|
||
.kb-modal-footer{display:flex;justify-content:flex-end;gap:10px;}
|
||
|
||
/* About modal prose */
|
||
.about-body h1,.about-body h2,.about-body h3{font-size:var(--fs-title);font-weight:700;margin:12px 0 4px;color:var(--text);}
|
||
.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;}
|
||
|
||
/* ── Footer ───────────────────────────────────────────────────────────────── */
|
||
.kb-footer{
|
||
max-width:960px;margin:0 auto;padding:16px 20px 10px;
|
||
font-size:var(--fs-small);color:var(--text-muted);
|
||
display:flex;align-items:center;gap:8px;flex-wrap:wrap;
|
||
}
|
||
.kb-footer a{color:var(--text-muted);text-decoration:none;}
|
||
.kb-footer a:hover{color:var(--accent);text-decoration:underline;}
|
||
.kb-footer .sep{opacity:.45;}
|
||
|
||
/* ── App wordmark (toolbar left) ──────────────────────────────────────────── */
|
||
.kb-doctitle h1{display:inline-flex;align-items:center;gap:9px;}
|
||
.kb-doctitle h1 svg{flex-shrink:0;}
|
||
/* Language select in toolbar */
|
||
.kb-toolbar select{
|
||
font:600 var(--fs-small)/1 var(--font-sans);color:var(--text-muted);
|
||
background:var(--surface);border:1px solid var(--border);
|
||
border-radius:var(--radius-sm);padding:5px 28px 5px 10px;
|
||
outline:none;appearance:none;cursor:pointer;
|
||
background-image:url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 16 16' fill='none' stroke='%235F6975' stroke-width='1.8'><path d='M4 6l4 4 4-4'/></svg>");
|
||
background-repeat:no-repeat;background-position:right 8px center;
|
||
transition:border-color .14s,color .14s;
|
||
}
|
||
.kb-toolbar select:focus{border-color:var(--accent);box-shadow:var(--ring);color:var(--text);}
|
||
.kb-toolbar select:hover{border-color:var(--accent-border);color:var(--text);}
|
||
|
||
/* ── Loading ──────────────────────────────────────────────────────────────── */
|
||
.kb-loading{text-align:center;padding:80px;color:var(--text-muted);}
|
||
|
||
/* ── Responsive ───────────────────────────────────────────────────────────── */
|
||
@media (max-width:680px){
|
||
.kb-grid.cols-2,.kb-grid.cols-3,.kb-grid.cols-4,.kb-grid.claim-grid{grid-template-columns:1fr;}
|
||
.kb-header{flex-direction:column;gap:14px;}
|
||
.kb-doctitle{text-align:left;}
|
||
.kb-line-section.row1,.kb-line-section.row2,.kb-line-section.row3{grid-template-columns:1fr;}
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<div id="app"><p class="kb-loading">Loading configuration…</p></div>
|
||
<footer class="kb-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)) };
|
||
}
|
||
|
||
// ── Field helper ──────────────────────────────────────────────────────────
|
||
function kbField(label, input, opts) {
|
||
const cls = 'kb-field' + (opts && opts.grow ? ' grow' : '') + (opts && opts.cls ? ' ' + opts.cls : '');
|
||
const f = el('div', {className: cls});
|
||
f.appendChild(el('span', {className:'kb-label'}, label));
|
||
f.appendChild(input);
|
||
if (opts && opts.hint) {
|
||
const h = el('div', {style:{fontSize:'var(--fs-small)',color:'var(--text-muted)',marginTop:'3px'}}, opts.hint);
|
||
f.appendChild(h);
|
||
}
|
||
return f;
|
||
}
|
||
|
||
// ========== DATE HELPERS ==========
|
||
function isDateInPeriod(date) {
|
||
if (!date || !state.periodFrom || !state.periodTo) return true;
|
||
return date >= state.periodFrom && date <= state.periodTo;
|
||
}
|
||
|
||
// ========== MODALS ==========
|
||
function showConfirmModal(msg) {
|
||
return new Promise(resolve => {
|
||
const overlay = el('div', {className:'kb-overlay'});
|
||
const box = el('div', {className:'kb-modal'});
|
||
const note = el('div', {className:'kb-note kb-note--warning', style:{marginBottom:'16px'}});
|
||
note.innerHTML = `<svg viewBox="0 0 16 16" fill="currentColor"><path d="M8.982 1.566a1.13 1.13 0 0 0-1.96 0L.165 13.233c-.457.778.091 1.767.98 1.767h13.713c.889 0 1.438-.99.98-1.767L8.982 1.566zM8 5c.535 0 .954.462.9.995l-.35 3.507a.552.552 0 0 1-1.1 0L7.1 5.995A.905.905 0 0 1 8 5zm.002 6a1 1 0 1 1 0 2 1 1 0 0 1 0-2z"/></svg><span></span>`;
|
||
note.querySelector('span').innerHTML = msg;
|
||
const footer = el('div', {className:'kb-modal-footer'});
|
||
const cancelBtn = el('button', {className:'kb-btn kb-btn--ghost'}, 'Cancel');
|
||
const confirmBtn = el('button', {className:'kb-btn kb-btn--primary'}, 'Yes, start new form');
|
||
cancelBtn.addEventListener('click', () => { overlay.remove(); resolve(false); });
|
||
confirmBtn.addEventListener('click', () => { overlay.remove(); resolve(true); });
|
||
footer.append(cancelBtn, confirmBtn);
|
||
box.append(note, footer);
|
||
overlay.appendChild(box);
|
||
overlay.addEventListener('click', e => { if (e.target === overlay) { overlay.remove(); resolve(false); } });
|
||
document.body.appendChild(overlay);
|
||
cancelBtn.focus();
|
||
});
|
||
}
|
||
|
||
function showWarningModal(msg) {
|
||
return new Promise(resolve => {
|
||
const overlay = el('div', {className:'kb-overlay'});
|
||
const box = el('div', {className:'kb-modal'});
|
||
const note = el('div', {className:'kb-note kb-note--info', style:{marginBottom:'16px'}});
|
||
note.innerHTML = `<svg viewBox="0 0 16 16" fill="currentColor"><path d="M8 0a8 8 0 1 1 0 16A8 8 0 0 1 8 0zm.9 6.8H7.1v5.4h1.8V6.8zM8 3.3a1.1 1.1 0 1 0 0 2.2 1.1 1.1 0 0 0 0-2.2z"/></svg><span></span>`;
|
||
note.querySelector('span').innerHTML = msg;
|
||
const footer = el('div', {className:'kb-modal-footer'});
|
||
const okBtn = el('button', {className:'kb-btn kb-btn--primary'}, 'OK');
|
||
okBtn.addEventListener('click', () => { overlay.remove(); resolve(); });
|
||
footer.appendChild(okBtn);
|
||
box.append(note, footer);
|
||
overlay.appendChild(box);
|
||
overlay.addEventListener('click', e => { if (e.target === overlay) { overlay.remove(); resolve(); } });
|
||
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>');
|
||
html = html.replace(/((?:^- .+\n?)+)/gm, m => '<ul>' + m.replace(/^- (.+)$/gm, '<li>$1</li>') + '</ul>');
|
||
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', {className:'kb-overlay'});
|
||
const box = el('div', {className:'kb-modal'});
|
||
box.appendChild(el('h2', null, title));
|
||
const body = el('div', {className:'about-body body'});
|
||
body.innerHTML = mdToHtml(content);
|
||
const footer = el('div', {className:'kb-modal-footer'});
|
||
const closeBtn = el('button', {className:'kb-btn kb-btn--primary'}, btnLabel);
|
||
closeBtn.addEventListener('click', () => overlay.remove());
|
||
overlay.addEventListener('click', e => { if (e.target === overlay) overlay.remove(); });
|
||
footer.appendChild(closeBtn);
|
||
box.append(body, footer);
|
||
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'] || '#2F6FED');
|
||
}
|
||
|
||
// ========== 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', fxDir: 'foreign', 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, fxDir: ln.fxDir || 'foreign', 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, fxDir: ld.fxDir || 'foreign', 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 = fmtAmt(grand);
|
||
const gl = $('#grand-total-label');
|
||
if (gl) gl.textContent = `Total claim (${state.baseCurrency})`;
|
||
}
|
||
|
||
// ========== 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', {className:'kb-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 appIconSVG() {
|
||
const s = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||
s.setAttribute('viewBox', '0 0 48 48');
|
||
s.setAttribute('aria-hidden', 'true');
|
||
s.style.cssText = 'width:100%;height:100%;display:block;';
|
||
s.innerHTML = `<rect width="48" height="48" rx="12" fill="var(--accent)"/><rect x="7.5" y="14" width="33" height="20" rx="3.2" fill="none" stroke="#fff" stroke-width="2.8"/><circle cx="24" cy="24" r="4.6" fill="none" stroke="#fff" stroke-width="2.6"/><path d="M12.7 21.4V26.6M35.3 21.4V26.6" stroke="#fff" stroke-width="2.6" stroke-linecap="round"/>`;
|
||
return s;
|
||
}
|
||
|
||
function render() {
|
||
const app = $('#app');
|
||
app.innerHTML = '';
|
||
const wrap = el('div', {className:'kb-wrap'});
|
||
|
||
// Validation summary placeholder
|
||
const valBox = el('div', {id:'val-box'});
|
||
wrap.appendChild(valBox);
|
||
|
||
// ── Toolbar ──────────────────────────────────────────────────────────────
|
||
const toolbar = el('div', {className:'kb-toolbar'});
|
||
|
||
// Language selector (only when CFG.languages has >1 entry) — goes first
|
||
if (Array.isArray(CFG.languages) && CFG.languages.length > 1) {
|
||
const langSel = el('select', {'aria-label':'Language'});
|
||
CFG.languages.forEach(lang => {
|
||
const code = typeof lang === 'object' ? lang.code : lang;
|
||
const name = typeof lang === 'object' ? lang.name : lang;
|
||
langSel.appendChild(el('option', {value: code}, name));
|
||
});
|
||
toolbar.appendChild(langSel);
|
||
}
|
||
|
||
toolbar.appendChild(el('div', {className:'spacer'}));
|
||
|
||
// Font size
|
||
const sizeSeg = el('div', {className:'kb-seg', role:'group', 'aria-label':'Text size'});
|
||
const sizeOpts = [{lbl:'A−', scale:0.9}, {lbl:'A', scale:1}, {lbl:'A+', scale:1.12}];
|
||
let activeSizeIdx = 1;
|
||
sizeOpts.forEach((s, i) => {
|
||
const btn = el('button', {type:'button', className: i === activeSizeIdx ? 'is-active' : ''}, s.lbl);
|
||
btn.addEventListener('click', () => {
|
||
$$('button', sizeSeg).forEach(b => b.classList.remove('is-active'));
|
||
btn.classList.add('is-active');
|
||
document.documentElement.style.setProperty('--font-scale', s.scale);
|
||
});
|
||
sizeSeg.appendChild(btn);
|
||
});
|
||
toolbar.appendChild(sizeSeg);
|
||
|
||
// Theme toggle (single icon button: moon = light mode, sun = dark mode)
|
||
const themeBtn = el('button', {className:'kb-iconbtn', type:'button', 'aria-label':'Toggle theme'});
|
||
const moonSVG = `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"/></svg>`;
|
||
const sunSVG = `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41"/></svg>`;
|
||
const currentTheme = () => document.documentElement.getAttribute('data-theme') ||
|
||
(window.matchMedia('(prefers-color-scheme:dark)').matches ? 'dark' : 'light');
|
||
const updateThemeBtn = () => { themeBtn.innerHTML = currentTheme() === 'dark' ? sunSVG : moonSVG; };
|
||
updateThemeBtn();
|
||
themeBtn.addEventListener('click', () => {
|
||
const next = currentTheme() === 'dark' ? 'light' : 'dark';
|
||
document.documentElement.setAttribute('data-theme', next);
|
||
localStorage.setItem('reimb-theme', next);
|
||
updateThemeBtn();
|
||
});
|
||
toolbar.appendChild(themeBtn);
|
||
|
||
// About
|
||
const aboutBtn = el('button', {className:'kb-iconbtn', type:'button', 'aria-label':'About', onClick: showAboutModal});
|
||
aboutBtn.innerHTML = `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4M12 8h.01"/></svg>`;
|
||
toolbar.appendChild(aboutBtn);
|
||
wrap.appendChild(toolbar);
|
||
|
||
// ── Header ────────────────────────────────────────────────────────────────
|
||
const hdr = el('div', {className:'kb-header'});
|
||
const brand = el('div', {className:'kb-brand'});
|
||
const logoBox = el('span', {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(appIconSVG()); };
|
||
};
|
||
if (CFG['logo-maxwidth']) img.style.maxWidth = CFG['logo-maxwidth'] * 28.3465 + 'px';
|
||
logoBox.appendChild(img);
|
||
} else {
|
||
logoBox.appendChild(appIconSVG());
|
||
}
|
||
const orgSpan = el('span', {className:'org'}, CFG.organization || '');
|
||
orgSpan.appendChild(el('small', null, 'Expense reimbursement'));
|
||
brand.append(logoBox, orgSpan);
|
||
|
||
const docTitle = el('div', {className:'kb-doctitle'});
|
||
const h1 = el('h1');
|
||
h1.innerHTML = `<svg width="24" height="24" viewBox="0 0 48 48" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><rect width="48" height="48" rx="12" fill="var(--accent)"/><rect x="7.5" y="14" width="33" height="20" rx="3.2" fill="none" stroke="#fff" stroke-width="2.8"/><circle cx="24" cy="24" r="4.6" fill="none" stroke="#fff" stroke-width="2.6"/><path d="M12.7 21.4V26.6M35.3 21.4V26.6" stroke="#fff" stroke-width="2.6" stroke-linecap="round"/></svg>reimburse`;
|
||
const periodMeta = el('div', {className:'meta'});
|
||
function fmtPeriodDate(iso) {
|
||
const [y, m, d] = iso.split('-').map(Number);
|
||
return `${d} ${new Date(y, m-1, 1).toLocaleString('en-GB', {month:'long'})}`;
|
||
}
|
||
function updatePeriodMeta() {
|
||
const from = state.periodFrom, to = state.periodTo;
|
||
if (from && to) periodMeta.textContent = `${fmtPeriodDate(from)} – ${fmtPeriodDate(to)}`;
|
||
else if (from) periodMeta.textContent = `From ${fmtPeriodDate(from)}`;
|
||
else if (to) periodMeta.textContent = `Until ${fmtPeriodDate(to)}`;
|
||
else periodMeta.textContent = '';
|
||
}
|
||
updatePeriodMeta();
|
||
docTitle.append(h1, periodMeta);
|
||
hdr.append(brand, docTitle);
|
||
wrap.appendChild(hdr);
|
||
|
||
// ── Claimant card ─────────────────────────────────────────────────────────
|
||
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', {className:'kb-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', {className:'kb-input', type:'date'});
|
||
fromInput.value = state.periodFrom;
|
||
fromInput.addEventListener('change', () => { state.periodFrom = fromInput.value; updatePeriodMeta(); });
|
||
|
||
const toInput = el('input', {className:'kb-input', type:'date'});
|
||
toInput.value = state.periodTo;
|
||
toInput.addEventListener('change', () => { state.periodTo = toInput.value; updatePeriodMeta(); });
|
||
|
||
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();
|
||
});
|
||
|
||
const claimCard = el('div', {className:'kb-card'});
|
||
claimCard.appendChild(el('h2', {className:'kb-card__title'}, 'Claimant'));
|
||
const claimGrid = el('div', {className:'kb-grid claim-grid', style:{gridTemplateColumns:'2fr 0.85fr 0.85fr 0.5fr'}});
|
||
claimGrid.appendChild(kbField('Staff', staffInput));
|
||
claimGrid.appendChild(kbField('Period from', fromInput));
|
||
claimGrid.appendChild(kbField('Period to', toInput));
|
||
claimGrid.appendChild(kbField('Base currency', baseCurDD));
|
||
claimCard.appendChild(claimGrid);
|
||
|
||
const newFormBtn = el('button', {type:'button', className:'kb-btn kb-btn--ghost', style:{marginTop:'12px'}, onClick: onNewForm}, 'New form');
|
||
claimCard.appendChild(newFormBtn);
|
||
wrap.appendChild(claimCard);
|
||
|
||
// ── Expenses card ─────────────────────────────────────────────────────────
|
||
const expCard = el('div', {className:'kb-card'});
|
||
const expTitle = el('h2', {className:'kb-card__title'}, 'Expenses');
|
||
expCard.appendChild(expTitle);
|
||
|
||
const itemsBox = el('div', {id:'items-box'});
|
||
state.items.forEach(item => itemsBox.appendChild(renderItem(item)));
|
||
expCard.appendChild(itemsBox);
|
||
|
||
const addItemBtn = el('button', {className:'kb-btn kb-btn--dashed', style:{marginTop:'4px'}, onClick: () => {
|
||
const item = newItem();
|
||
state.items.push(item);
|
||
itemsBox.appendChild(renderItem(item));
|
||
recalc();
|
||
}}, '+ Add expense item');
|
||
expCard.appendChild(addItemBtn);
|
||
wrap.appendChild(expCard);
|
||
|
||
// ── Totals + actions card ─────────────────────────────────────────────────
|
||
const actCard = el('div', {className:'kb-card'});
|
||
const totals = el('div', {className:'kb-totals'});
|
||
const grandRow = el('div', {className:'grand'});
|
||
grandRow.append(
|
||
el('span', {className:'lab', id:'grand-total-label'}, `Total claim (${state.baseCurrency})`),
|
||
el('span', {className:'val kb-mono', id:'grand-total'}, fmtAmt(0))
|
||
);
|
||
totals.appendChild(grandRow);
|
||
actCard.appendChild(totals);
|
||
|
||
const actionRow = el('div', {style:{display:'flex', gap:'12px', marginTop:'16px', flexWrap:'wrap'}});
|
||
const saveBtn = el('button', {className:'kb-btn kb-btn--soft', onClick: onSave}, 'Save form');
|
||
const genBtn = el('button', {className:'kb-btn kb-btn--primary kb-btn--lg', id:'gen-btn', style:{flex:'1'}, onClick: onGenerate});
|
||
genBtn.innerHTML = `<svg viewBox="0 0 16 16" fill="currentColor" width="16" height="16"><path d="M8 1a1 1 0 0 1 1 1v6.6l2.3-2.3a1 1 0 0 1 1.4 1.4l-4 4a1 1 0 0 1-1.4 0l-4-4a1 1 0 0 1 1.4-1.4L7 8.6V2a1 1 0 0 1 1-1zM3 13h10a1 1 0 1 1 0 2H3a1 1 0 1 1 0-2z"/></svg>Generate PDF`;
|
||
actionRow.append(saveBtn, genBtn);
|
||
actCard.appendChild(actionRow);
|
||
wrap.appendChild(actCard);
|
||
|
||
app.appendChild(wrap);
|
||
recalc();
|
||
}
|
||
|
||
function renderItem(item) {
|
||
const blk = el('div', {className:'kb-block', id:`item-${item.id}`});
|
||
const itemNum = state.items.indexOf(item) + 1;
|
||
|
||
const nameIn = el('input', {className:'kb-input kb-block__name', type:'text', value: item.name, placeholder:'Item / project / travel name'});
|
||
nameIn.addEventListener('input', () => { item.name = nameIn.value; });
|
||
|
||
const rmBtn = el('button', {className:'kb-btn kb-btn--danger', type:'button', onClick: () => {
|
||
state.items = state.items.filter(i => i.id !== item.id);
|
||
blk.remove();
|
||
recalc();
|
||
}}, 'Remove');
|
||
|
||
const head = el('div', {className:'kb-block__head'});
|
||
head.append(
|
||
el('span', {className:'kb-block__tag'}, `Item ${itemNum}`),
|
||
nameIn,
|
||
el('span', {className:'kb-subtotal', id:`sub-${item.id}`}, `${state.baseCurrency} ${fmtAmt(0)}`),
|
||
rmBtn
|
||
);
|
||
blk.appendChild(head);
|
||
|
||
const linesBox = el('div', {id:`lines-${item.id}`});
|
||
item.lines.forEach(ln => linesBox.appendChild(renderLine(ln, item)));
|
||
blk.appendChild(linesBox);
|
||
|
||
blk.appendChild(el('button', {className:'kb-btn kb-btn--dashed', 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:'kb-line', id:`line-${ln.id}`});
|
||
let progAreaEl;
|
||
|
||
const currencies = CFG.currencies || [];
|
||
const baseCur = state.baseCurrency;
|
||
|
||
// Date input
|
||
const dateIn = el('input', {className:'kb-input' + (ln.date && !isDateInPeriod(ln.date) ? ' is-warn' : ''), type:'date'});
|
||
if (ln.date) dateIn.value = ln.date;
|
||
dateIn.addEventListener('change', async () => {
|
||
ln.date = dateIn.value;
|
||
if (ln.date && !isDateInPeriod(ln.date)) {
|
||
dateIn.classList.add('is-warn');
|
||
await showWarningModal('The date is outside the claim period. You can continue, but it will be flagged on the PDF.');
|
||
} else {
|
||
dateIn.classList.remove('is-warn');
|
||
}
|
||
});
|
||
|
||
// Vendor
|
||
const vendIn = el('input', {className:'kb-input', type:'text', value: ln.vendor, placeholder:'Vendor name'});
|
||
vendIn.addEventListener('input', () => { ln.vendor = vendIn.value; });
|
||
|
||
// FX — bidirectional rate entry
|
||
// Canonical: ln.fxRate is always "foreign per 1 base" (divide to get base amount).
|
||
// ln.fxDir controls display direction: 'foreign' = show canonical, 'base' = show 1/canonical.
|
||
const fxIn = el('input', {className:'kb-input num', type:'text', placeholder:'0.00000', style:{minWidth:'0', width:'90px'}});
|
||
const fxDirContainer = el('div', {style:{display:'inline-block'}});
|
||
const fxPerLbl = el('span', {style:{fontSize:'var(--fs-small)', color:'var(--text-muted)', whiteSpace:'nowrap', userSelect:'none'}});
|
||
const fxWrap = el('div', {style:{display:'flex', alignItems:'center', gap:'4px'}});
|
||
fxWrap.append(fxIn, fxDirContainer, fxPerLbl);
|
||
|
||
let fxDirDD = null;
|
||
|
||
function fxDisplayVal() {
|
||
if (!ln.fxRate || ln.fxRate === '' || ln.fxRate === '1.00000') return ln.fxRate;
|
||
const r = parseFloat(ln.fxRate);
|
||
if (ln.fxDir === 'base' && r > 0) return (1 / r).toFixed(5);
|
||
return ln.fxRate;
|
||
}
|
||
function fxStoreFromDisplay(displayVal) {
|
||
const v = parseFloat(displayVal);
|
||
if (isNaN(v) || v <= 0) { ln.fxRate = displayVal; return; }
|
||
ln.fxRate = ln.fxDir === 'base' ? (1 / v).toFixed(5) : displayVal;
|
||
}
|
||
function rebuildFxDirSel() {
|
||
const fcy = ln.currency || '???';
|
||
const initCode = ln.fxDir === 'base' ? baseCur : fcy;
|
||
const opts = [
|
||
{code: fcy, name: getCurrencyName(fcy) || fcy},
|
||
{code: baseCur, name: getCurrencyName(baseCur) || baseCur}
|
||
];
|
||
fxDirDD = makeCDD(opts, initCode, code => {
|
||
ln.fxDir = code === baseCur ? 'base' : 'foreign';
|
||
fxIn.value = fxDisplayVal();
|
||
fxPerLbl.textContent = 'per ' + (ln.fxDir === 'foreign' ? baseCur : ln.currency || '???');
|
||
});
|
||
fxDirContainer.innerHTML = '';
|
||
fxDirContainer.appendChild(fxDirDD);
|
||
fxPerLbl.textContent = 'per ' + (ln.fxDir === 'foreign' ? baseCur : fcy);
|
||
}
|
||
function showFxControls(show) {
|
||
fxDirContainer.style.display = show ? '' : 'none';
|
||
fxPerLbl.style.display = show ? '' : 'none';
|
||
}
|
||
|
||
fxIn.value = fxDisplayVal();
|
||
fxIn.readOnly = !ln.customCurrency && ln.currency === baseCur;
|
||
rebuildFxDirSel();
|
||
showFxControls(ln.currency !== baseCur);
|
||
|
||
fxIn.addEventListener('input', () => {
|
||
fxStoreFromDisplay(fxIn.value);
|
||
const rate = parseFloat(ln.fxRate);
|
||
if (ln.currency && ln.currency !== baseCur && rate > 0) state.fxRateMemory[ln.currency] = ln.fxRate;
|
||
recalc();
|
||
if (progAreaEl) progAreaEl._refresh();
|
||
});
|
||
|
||
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'; ln.fxDir = 'foreign';
|
||
fxIn.value = '1.00000'; fxIn.readOnly = true;
|
||
showFxControls(false);
|
||
} else {
|
||
fxIn.readOnly = false;
|
||
const mem = state.fxRateMemory[code];
|
||
if (mem) { ln.fxRate = mem; }
|
||
else if (ln.fxRate === '1.00000') { ln.fxRate = ''; }
|
||
rebuildFxDirSel();
|
||
fxIn.value = fxDisplayVal();
|
||
showFxControls(true);
|
||
}
|
||
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', {className:'kb-input', type:'text', maxlength:'3', placeholder:'e.g. THB',
|
||
style:{textTransform:'uppercase', letterSpacing:'1px', minWidth:'0', width:'80px'}});
|
||
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:'kb-btn kb-btn--danger', style:{padding:'6px 8px'}}, '×');
|
||
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:{display: ln.customCurrency ? 'flex' : 'none', gap:'4px', alignItems:'center'}});
|
||
otherCurWrap.append(otherCurIn, cancelOtherBtn);
|
||
|
||
const curWrap = el('div');
|
||
curWrap.append(curDD, otherCurWrap);
|
||
|
||
// Row 1: Date | Vendor | Currency | FX rate
|
||
const row1 = el('div', {className:'kb-line-section row1'});
|
||
row1.append(
|
||
kbField('Date', dateIn),
|
||
kbField('Vendor', vendIn),
|
||
kbField('Currency', curWrap),
|
||
kbField('FX rate', fxWrap)
|
||
);
|
||
blk.appendChild(row1);
|
||
|
||
// Description, Receipt, Amount
|
||
const descIn = el('input', {className:'kb-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', {className:'kb-input num', type:'text', value: ln.amount, placeholder:'0.00', style:{minWidth:'0'}});
|
||
amtIn.addEventListener('input', () => { ln.amount = amtIn.value; recalc(); if (progAreaEl) progAreaEl._refresh(); });
|
||
|
||
const row2 = el('div', {className:'kb-line-section row2'});
|
||
row2.append(
|
||
kbField('Description', descIn),
|
||
kbField('Receipt', receiptSel),
|
||
kbField('Amount', amtIn)
|
||
);
|
||
blk.appendChild(row2);
|
||
|
||
// Account, Program
|
||
const acctSel = makeSelect(CFG.accounts || [], ln.account, v => { ln.account = v; }, 'Select account');
|
||
progAreaEl = buildProgramArea(ln);
|
||
|
||
const row3 = el('div', {className:'kb-line-section row3'});
|
||
row3.append(
|
||
kbField('Account', acctSel),
|
||
kbField('Program', progAreaEl)
|
||
);
|
||
blk.appendChild(row3);
|
||
|
||
// Receipt area
|
||
const receiptArea = el('div');
|
||
receiptArea.appendChild(buildReceiptArea(ln));
|
||
blk.appendChild(receiptArea);
|
||
|
||
// Remove line
|
||
const rmBtn = el('button', {className:'kb-btn kb-btn--danger', style:{marginTop:'8px'}, 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:'rb-receipt-area'});
|
||
if (ln.hasReceipt) {
|
||
ln.receipts.forEach((r, i) => {
|
||
const row = el('div', {className:'rb-receipt'});
|
||
row.innerHTML = `<svg viewBox="0 0 16 16" fill="currentColor"><path d="M3 1.5A1.5 1.5 0 0 1 4.5 0h4.7c.4 0 .8.16 1.06.44l2.3 2.3c.28.27.44.66.44 1.06v8.7A1.5 1.5 0 0 1 11.5 16h-7A1.5 1.5 0 0 1 3 14.5zM9 1.5V4h2.5z"/></svg>`;
|
||
row.appendChild(el('span', {className:'name'}, r.name));
|
||
const rmBtn = el('button', {className:'rm', type:'button'}, '×');
|
||
rmBtn.addEventListener('click', () => { ln.receipts.splice(i, 1); area.replaceWith(buildReceiptArea(ln)); saveState(); });
|
||
row.appendChild(rmBtn);
|
||
area.appendChild(row);
|
||
});
|
||
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();
|
||
});
|
||
const addBtn = el('button', {className:'kb-btn kb-btn--dashed', style:{marginTop:'6px'}, onClick: () => fileIn.click()}, '+ Add receipt');
|
||
area.append(addBtn, fileIn);
|
||
} else {
|
||
const ta = el('textarea', {className:'kb-textarea rb-no-receipt', placeholder:'Explain why there is no receipt', rows:'2'});
|
||
ta.value = ln.noReceiptExplanation || '';
|
||
ta.addEventListener('input', () => { ln.noReceiptExplanation = ta.value; });
|
||
area.appendChild(kbField('Reason for no receipt', 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 ? 'var(--warning)' : sum <= 100.005 ? 'var(--success)' : 'var(--danger)';
|
||
}
|
||
}
|
||
|
||
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', {className:'kb-input', type:'text', value: pe.programOther, placeholder:'Specify program',
|
||
style:{display: pe.program === 'Other' ? '' : 'none', marginTop:'6px'}});
|
||
otherIn.addEventListener('input', () => { pe.programOther = otherIn.value; });
|
||
|
||
if (!isMulti) {
|
||
const addBtn = el('button', {type:'button', className:'kb-btn kb-btn--dashed', style:{whiteSpace:'nowrap'}}, '+ 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', {className:'kb-input num', type:'number', min:'0', max:'100', step:'0.01',
|
||
value: pe.percent, style:{width:'80px'}});
|
||
pctIn.addEventListener('input', () => { pe.percent = pctIn.value; updateDerived(); });
|
||
|
||
const amtSpan = el('span', {className:'kb-mono', style:{fontSize:'var(--fs-small)', color:'var(--text-muted)', whiteSpace:'nowrap'}});
|
||
amtSpans.push(amtSpan);
|
||
|
||
const rmBtn = el('button', {type:'button', className:'kb-btn kb-btn--danger', style:{padding:'4px 8px'}}, '×');
|
||
rmBtn.addEventListener('click', () => { ln.programs.splice(pi, 1); rebuild(); });
|
||
|
||
const row = el('div', {style:{display:'flex', gap:'8px', alignItems:'center', marginBottom:'6px', flexWrap:'wrap'}});
|
||
row.append(progSel,
|
||
el('span', {style:{fontSize:'var(--fs-small)', color:'var(--text-muted)', whiteSpace:'nowrap'}}, '%:'),
|
||
pctIn,
|
||
el('span', {style:{fontSize:'var(--fs-small)', color:'var(--text-muted)'}}, '–'),
|
||
amtSpan, rmBtn);
|
||
wrapper.append(row, otherIn);
|
||
}
|
||
});
|
||
|
||
if (isMulti) {
|
||
totalSpan = el('span', {className:'kb-mono', style:{fontWeight:'600', fontSize:'var(--fs-small)'}});
|
||
const totalRow = el('div', {style:{display:'flex', justifyContent:'flex-end', alignItems:'center', gap:'6px', marginTop:'4px', marginBottom:'4px'}},
|
||
[el('span', {style:{fontSize:'var(--fs-small)', color:'var(--text-muted)'}}, 'Total:'), totalSpan]);
|
||
wrapper.appendChild(totalRow);
|
||
|
||
const addBtn = el('button', {type:'button', className:'kb-btn kb-btn--dashed', style:{marginTop:'4px'}}, '+ 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 expense 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: 52, bottom: 65, left: 48, right: 48 };
|
||
const W = pageW - M.left - M.right;
|
||
|
||
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 szXs = Math.max(sz - 2, 7); // eyebrow labels
|
||
const szLg = sz + 4; // section headings, grand total figure
|
||
const lh = sz + 5; // line height
|
||
|
||
// ── kBenestad color tokens ────────────────────────────────────────────────
|
||
const clrAccent = parseHex(CFG['accent-colour'] || '#2F6FED');
|
||
const clrText = rgb(0.078, 0.094, 0.118); // #14181E
|
||
const clrTextSoft = rgb(0.227, 0.263, 0.310); // #3A434F
|
||
const clrMuted = rgb(0.373, 0.412, 0.459); // #5F6975
|
||
const clrBorder = rgb(0.890, 0.918, 0.933); // #E3E7EE
|
||
const clrBorderStrong = rgb(0.827, 0.851, 0.886); // #D3D9E2
|
||
const clrSurface2 = rgb(0.973, 0.976, 0.984); // #F8F9FB
|
||
const clrWarn = rgb(0.788, 0.318, 0.000); // out-of-period date
|
||
|
||
const baseCur = state.baseCurrency;
|
||
|
||
let logoImage = null;
|
||
if (CFG.logo === true || CFG.logo === 'yes') logoImage = await loadLogo(doc);
|
||
|
||
const pages = [];
|
||
let pg, y;
|
||
const receiptRefs = [];
|
||
let justBroke = false;
|
||
|
||
// ── Drawing helpers ───────────────────────────────────────────────────────
|
||
|
||
// Eyebrow label: uppercase, bold, muted, szXs
|
||
function lbl(text, x, yy) {
|
||
pg.drawText(text.toUpperCase(), { x, y: yy, size: szXs, font: fontBold, color: clrMuted });
|
||
}
|
||
// Body value in Helvetica
|
||
function val(text, x, yy, extra) {
|
||
pg.drawText(text, { x, y: yy, size: sz, font: fontBody, color: clrText, ...(extra||{}) });
|
||
}
|
||
// Mono value (amounts, dates, FX rates) in Courier
|
||
function mono(text, x, yy, extra) {
|
||
pg.drawText(text, { x, y: yy, size: sz, font: fontMono, color: clrText, ...(extra||{}) });
|
||
}
|
||
// 3pt accent left-stripe for item/section headers
|
||
function accentStripe(yy) {
|
||
pg.drawRectangle({ x: M.left, y: yy - 1, width: 3, height: lh + 1, color: clrAccent });
|
||
}
|
||
|
||
// ── Page management ───────────────────────────────────────────────────────
|
||
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);
|
||
}
|
||
|
||
// Continuation header: light strip with staff + period
|
||
function drawContHeader() {
|
||
const stripH = szXs + lh + 14;
|
||
pg.drawRectangle({ x: 0, y: y - stripH, width: pageW, height: stripH, color: clrSurface2 });
|
||
pg.drawLine({ start:{x:0, y}, end:{x:pageW, y}, thickness:0.5, color:clrBorder });
|
||
pg.drawLine({ start:{x:0, y:y-stripH}, end:{x:pageW, y:y-stripH}, thickness:0.5, color:clrBorder });
|
||
|
||
const labY = y - 7;
|
||
const valY = labY - szXs - 5;
|
||
lbl('Staff', M.left, labY);
|
||
lbl('Period', M.left + W * 0.45, labY);
|
||
pg.drawText(state.staff, { x:M.left, y:valY, size:sz, font:fontBold, color:clrText });
|
||
mono(`${state.periodFrom} to ${state.periodTo}`, M.left + W * 0.45, valY);
|
||
|
||
y -= stripH + 12;
|
||
}
|
||
|
||
// ── Page 1 ────────────────────────────────────────────────────────────────
|
||
addPage(true);
|
||
|
||
// Header: brand block left | doc title right
|
||
const hdrTopY = y;
|
||
const boxSize = 44;
|
||
|
||
if (logoImage) {
|
||
const maxWLogo = (CFG['logo-maxwidth'] || 4) * 28.3465;
|
||
const scale = Math.min(maxWLogo / logoImage.width, boxSize / logoImage.height, 1);
|
||
const lw = logoImage.width * scale, lhImg = logoImage.height * scale;
|
||
pg.drawImage(logoImage, { x: M.left, y: hdrTopY - lhImg, width: lw, height: lhImg });
|
||
} else {
|
||
// Initials box (surface-2 fill, border, accent initials)
|
||
const raw = (CFG.organization || 'ORG').trim();
|
||
const initials = raw.split(/\s+/).map(w => w[0] || '').join('').toUpperCase().slice(0, 2) || '??';
|
||
pg.drawRectangle({ x: M.left, y: hdrTopY - boxSize, width: boxSize, height: boxSize,
|
||
color: clrSurface2, borderColor: clrBorder, borderWidth: 0.5 });
|
||
const initW = fontBold.widthOfTextAtSize(initials, szLg);
|
||
pg.drawText(initials, { x: M.left + (boxSize - initW) / 2, y: hdrTopY - boxSize / 2 - szLg * 0.35,
|
||
size: szLg, font: fontBold, color: clrAccent });
|
||
}
|
||
|
||
// Org name + subtitle
|
||
const orgX = M.left + boxSize + 11;
|
||
pg.drawText(CFG.organization || '', { x: orgX, y: hdrTopY - 15, size: sz + 2, font: fontBold, color: clrText });
|
||
pg.drawText('Expense reimbursement', { x: orgX, y: hdrTopY - 15 - (sz + 2) - 4, size: szSm, font: fontBody, color: clrMuted });
|
||
|
||
// Title + claim date (right-aligned)
|
||
const titleStr = 'Reimbursement';
|
||
const titleW = fontBold.widthOfTextAtSize(titleStr, szLg);
|
||
pg.drawText(titleStr, { x: M.left + W - titleW, y: hdrTopY - 15, size: szLg, font: fontBold, color: clrText });
|
||
|
||
const nowD = new Date();
|
||
const MONTHS = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
|
||
const claimDate = `Claim · ${nowD.getDate()} ${MONTHS[nowD.getMonth()]} ${nowD.getFullYear()}`;
|
||
const claimDateW = fontMono.widthOfTextAtSize(claimDate, szSm);
|
||
pg.drawText(claimDate, { x: M.left + W - claimDateW, y: hdrTopY - 15 - szLg - 4, size: szSm, font: fontMono, color: clrMuted });
|
||
|
||
y = hdrTopY - boxSize - 10;
|
||
|
||
// Hairline below header
|
||
pg.drawLine({ start:{x:M.left, y}, end:{x:M.left+W, y}, thickness:0.5, color:clrBorder });
|
||
y -= 12;
|
||
|
||
// 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:clrMuted}); y -= lh; });
|
||
y -= 6;
|
||
}
|
||
|
||
// Staff / Period / Currency info block
|
||
const infoH = szXs + lh + 14;
|
||
pg.drawRectangle({ x: M.left - 8, y: y - infoH, width: W + 16, height: infoH, color: clrSurface2 });
|
||
pg.drawLine({ start:{x:M.left-8, y}, end:{x:M.left+W+8, y}, thickness:0.5, color:clrBorder });
|
||
pg.drawLine({ start:{x:M.left-8, y:y-infoH}, end:{x:M.left+W+8, y:y-infoH}, thickness:0.5, color:clrBorder });
|
||
|
||
const iLabY = y - 7;
|
||
const iValY = iLabY - szXs - 5;
|
||
const iC2 = W * 0.45, iC3 = W * 0.78;
|
||
|
||
lbl('Staff', M.left, iLabY);
|
||
lbl('Period', M.left + iC2, iLabY);
|
||
lbl('Currency', M.left + iC3, iLabY);
|
||
|
||
pg.drawText(state.staff, { x: M.left, y: iValY, size: sz, font: fontBold, color: clrText });
|
||
mono(`${state.periodFrom} to ${state.periodTo}`, M.left + iC2, iValY);
|
||
pg.drawText(baseCur, { x: M.left + iC3, y: iValY, size: sz, font: fontBold, color: clrAccent });
|
||
|
||
y -= infoH + 14;
|
||
|
||
// ── Items ─────────────────────────────────────────────────────────────────
|
||
state.items.forEach(item => {
|
||
needSpace(lh * 7);
|
||
|
||
// Section header: accent stripe + item name left, subtotal right
|
||
accentStripe(y);
|
||
pg.drawText(item.name || '(no name)', { x: M.left + 8, y, size: sz, font: fontBold, color: clrTextSoft });
|
||
const subStr = `${baseCur} ${fmtAmt(item._subtotal)}`;
|
||
const subW = fontMono.widthOfTextAtSize(subStr, sz);
|
||
pg.drawText(subStr, { x: M.left + W - subW, y, size: sz, font: fontMono, color: clrAccent });
|
||
y -= lh + 2;
|
||
|
||
// Hairline below section header
|
||
pg.drawLine({ start:{x:M.left, y}, end:{x:M.left+W, y}, thickness:0.5, color:clrBorder });
|
||
y -= 8;
|
||
|
||
// Lines
|
||
item.lines.forEach((ln, li) => {
|
||
needSpace(lh * 9);
|
||
if (li > 0 && !justBroke) {
|
||
y -= 4;
|
||
pg.drawLine({ start:{x:M.left, y}, end:{x:M.left+W, y}, thickness:0.3, color:clrBorder });
|
||
y -= 8;
|
||
}
|
||
|
||
const c1=0, c2=W*0.22, c3=W*0.64;
|
||
|
||
// Row 1 — Date | Vendor | Currency | FX rate
|
||
lbl('Date', M.left + c1, y);
|
||
lbl('Vendor', M.left + c2, y);
|
||
lbl('Currency', M.left + c3, y);
|
||
const fxLblStr = 'FX RATE';
|
||
pg.drawText(fxLblStr, { x: M.left + W - fontBold.widthOfTextAtSize(fxLblStr, szXs), y,
|
||
size: szXs, font: fontBold, color: clrMuted });
|
||
y -= szXs + 4;
|
||
|
||
const dateInPeriod = isDateInPeriod(ln.date);
|
||
mono((ln.date || '–') + (dateInPeriod ? '' : ' (!)'), M.left + c1, y,
|
||
{ color: dateInPeriod ? clrText : clrWarn });
|
||
val(truncate(ln.vendor, fontBody, sz, (c3 - c2) - 8), M.left + c2, y);
|
||
val(ln.currency || '–', M.left + c3, y);
|
||
const fxStr = ln.currency === baseCur ? '–' : parseFloat(ln.fxRate).toFixed(5);
|
||
const fxW = fontMono.widthOfTextAtSize(fxStr, sz);
|
||
mono(fxStr, M.left + W - fxW, y);
|
||
y -= lh + 4;
|
||
|
||
// Row 2 — Description | Receipt | Amount
|
||
lbl('Description', M.left, y);
|
||
lbl('Receipt', M.left + c3, y);
|
||
const amtLblStr = 'AMOUNT';
|
||
pg.drawText(amtLblStr, { x: M.left + W - fontBold.widthOfTextAtSize(amtLblStr, szXs), y,
|
||
size: szXs, font: fontBold, color: clrMuted });
|
||
y -= szXs + 4;
|
||
|
||
val(truncate(ln.description, fontBody, sz, c3 - 8), M.left, y);
|
||
val(ln.hasReceipt ? 'Yes' : 'No', M.left + c3, y);
|
||
const amtStr = `${ln.currency} ${fmtAmt(ln.amount)}`;
|
||
const amtW = fontMono.widthOfTextAtSize(amtStr, sz);
|
||
mono(amtStr, M.left + W - amtW, y);
|
||
y -= lh + 4;
|
||
|
||
// Row 3 — Account | Program
|
||
lbl('Account', M.left, y);
|
||
lbl('Program', M.left + W * 0.5, y);
|
||
y -= szXs + 4;
|
||
|
||
val(truncate(ln.account || '–', fontBody, sz, W * 0.5 - 8), M.left, y);
|
||
const progs = ln.programs || [];
|
||
if (progs.length <= 1) {
|
||
const pe = progs[0] || {};
|
||
const progStr = pe.program === 'Other' ? `Other: ${pe.programOther}` : (pe.program || '–');
|
||
val(truncate(progStr, fontBody, sz, W * 0.5 - 8), M.left + W * 0.5, y);
|
||
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)}`;
|
||
val(truncate(progStr, fontBody, sz, W * 0.42 - 8), M.left + W * 0.5, y);
|
||
const sfxW = fontMono.widthOfTextAtSize(suffix, szSm);
|
||
pg.drawText(suffix, { x: M.left + W - sfxW, y, size: szSm, font: fontMono, color: clrMuted });
|
||
y -= lh;
|
||
});
|
||
}
|
||
|
||
// Receipt page reference (filled in after receipt pages are built)
|
||
if (ln.hasReceipt && ln.receipts.length > 0) {
|
||
ln.receipts.forEach((r, ri) => {
|
||
const key = `${ln.id}-${ri}`;
|
||
receiptRefs.push({ pageIdx: pages.length - 1, x: M.left + W * 0.55, y, key });
|
||
y -= lh;
|
||
});
|
||
} else if (!ln.hasReceipt) {
|
||
needSpace(lh * 2);
|
||
lbl('Reason for no receipt', M.left, y);
|
||
y -= szXs + 4;
|
||
const explLines = wrapText(ln.noReceiptExplanation || '–', fontBody, sz, W);
|
||
explLines.forEach(line => { needSpace(lh); val(line, M.left, y); y -= lh; });
|
||
}
|
||
|
||
y -= 6;
|
||
});
|
||
|
||
y -= 10;
|
||
});
|
||
|
||
// Grand total
|
||
needSpace(lh * 3 + 6);
|
||
y -= 6;
|
||
pg.drawLine({ start:{x:M.left, y}, end:{x:M.left+W, y}, thickness:1.5, color:clrBorderStrong });
|
||
y -= lh;
|
||
|
||
pg.drawText('Total reimbursement claim', { x: M.left, y, size: sz, font: fontBold, color: clrTextSoft });
|
||
const gtStr = `${baseCur} ${fmtAmt(state._grandTotal)}`;
|
||
const gtW = fontBold.widthOfTextAtSize(gtStr, szLg);
|
||
pg.drawText(gtStr, { x: M.left + W - gtW, y: y - (szLg - sz) / 2, size: szLg, font: fontBold, color: clrAccent });
|
||
|
||
// ── Receipt pages ─────────────────────────────────────────────────────────
|
||
const formPageCount = pages.length;
|
||
const receiptPageMap = {};
|
||
|
||
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;
|
||
|
||
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) {
|
||
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:clrMuted});
|
||
}
|
||
} else {
|
||
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, 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;
|
||
rp.drawImage(img, { x: M.left + (maxW2-iw)/2, y: M.bottom + (maxH2-ih)/2, 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;
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── Back-fill receipt page references ─────────────────────────────────────
|
||
receiptRefs.forEach(ref => {
|
||
const pageNum = receiptPageMap[ref.key];
|
||
if (pageNum != null)
|
||
pages[ref.pageIdx].drawText(`See page ${pageNum} for receipt`,
|
||
{ x:ref.x, y:ref.y, size:szSm, font:fontBody, color:clrMuted });
|
||
});
|
||
|
||
// ── Footers on every page ─────────────────────────────────────────────────
|
||
const totalPages = pages.length;
|
||
const nowTs = new Date();
|
||
const printed = `${nowTs.getFullYear()}-${String(nowTs.getMonth()+1).padStart(2,'0')}-${String(nowTs.getDate()).padStart(2,'0')} ${String(nowTs.getHours()).padStart(2,'0')}:${String(nowTs.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:clrBorder });
|
||
p.drawText('Reimbursement form', { x:M.left, y:fy, size:szXs, font:fontBody, color:clrMuted });
|
||
p.drawText(state.staff, { x:M.left, y:fy-lh+2, size:szXs, font:fontBody, color:clrMuted });
|
||
const pgStr = `Page ${i+1}/${totalPages}`;
|
||
const pgW2 = fontBody.widthOfTextAtSize(pgStr, szXs);
|
||
p.drawText(pgStr, { x:M.left+W-pgW2, y:fy, size:szXs, font:fontBody, color:clrMuted });
|
||
const prStr = `Printed: ${printed}`;
|
||
const prW = fontBody.widthOfTextAtSize(prStr, szXs);
|
||
p.drawText(prStr, { x:M.left+W-prW, y:fy-lh+2, size:szXs, font:fontBody, color:clrMuted });
|
||
});
|
||
|
||
// ── Save and download ──────────────────────────────────────────────────────
|
||
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;
|
||
a.download = `reimbursement_${state.staff.replace(/\s+/g,'_')}_${state.periodFrom}_${state.periodTo}.pdf`;
|
||
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 note = el('div', {className:'kb-note kb-note--error'});
|
||
note.innerHTML = `<svg viewBox="0 0 16 16" fill="currentColor"><path d="M8.982 1.566a1.13 1.13 0 0 0-1.96 0L.165 13.233c-.457.778.091 1.767.98 1.767h13.713c.889 0 1.438-.99.98-1.767L8.982 1.566zM8 5c.535 0 .954.462.9.995l-.35 3.507a.552.552 0 0 1-1.1 0L7.1 5.995A.905.905 0 0 1 8 5zm.002 6a1 1 0 1 1 0 2 1 1 0 0 1 0-2z"/></svg>`;
|
||
const txt = el('div');
|
||
txt.innerHTML = '<strong>Please fix the following before generating:</strong><br>' + errs.join('<br>');
|
||
note.appendChild(txt);
|
||
valBox.appendChild(note);
|
||
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.innerHTML = `<svg viewBox="0 0 16 16" fill="currentColor" width="16" height="16"><path d="M8 1a1 1 0 0 1 1 1v6.6l2.3-2.3a1 1 0 0 1 1.4 1.4l-4 4a1 1 0 0 1-1.4 0l-4-4a1 1 0 0 1 1.4-1.4L7 8.6V2a1 1 0 0 1 1-1zM3 13h10a1 1 0 1 1 0 2H3a1 1 0 1 1 0-2z"/></svg>Generate PDF`;
|
||
}
|
||
|
||
// ========== NEW FORM HANDLER ==========
|
||
async function onNewForm() {
|
||
const confirmed = await showConfirmModal('This will clear all current form data and start a fresh form. Are you sure?');
|
||
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 note = el('div', {className:'kb-note kb-note--error'});
|
||
note.innerHTML = `<svg viewBox="0 0 16 16" fill="currentColor"><path d="M8.982 1.566a1.13 1.13 0 0 0-1.96 0L.165 13.233c-.457.778.091 1.767.98 1.767h13.713c.889 0 1.438-.99.98-1.767L8.982 1.566zM8 5c.535 0 .954.462.9.995l-.35 3.507a.552.552 0 0 1-1.1 0L7.1 5.995A.905.905 0 0 1 8 5zm.002 6a1 1 0 1 1 0 2 1 1 0 0 1 0-2z"/></svg>`;
|
||
const txt = el('div');
|
||
txt.innerHTML = '<strong>Please fix the following:</strong><br>' + errs.join('<br>');
|
||
note.appendChild(txt);
|
||
valBox.appendChild(note);
|
||
valBox.scrollIntoView({behavior:'smooth'});
|
||
return;
|
||
}
|
||
await saveState();
|
||
await showWarningModal('Form saved in this browser. The data will be visible next time you visit in <strong>this browser on this device</strong>. Clearing your browser history or cache will remove it.');
|
||
}
|
||
|
||
// ========== 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 form saves your input in the browser. As long as you do not clear your browser history or cache, the information is preserved on your next visit.';
|
||
if (!canSaveReceipts) entryMsg += '<br><br>Note: PDFs and images cannot be saved in the browser. Do not attach receipts until you are ready to generate and submit the form.';
|
||
await showWarningModal(entryMsg);
|
||
} catch (e) {
|
||
$('#app').innerHTML = `<div class="kb-wrap"><div class="kb-note kb-note--error"><span>Failed to load: ${e.message}</span></div></div>`;
|
||
console.error(e);
|
||
}
|
||
}
|
||
|
||
const aboutLink = document.getElementById('about-link');
|
||
if (aboutLink) aboutLink.addEventListener('click', e => { e.preventDefault(); showAboutModal(); });
|
||
|
||
init();
|
||
})();
|
||
</script>
|
||
</body>
|
||
</html>
|