invoice/app/index.html
Claude 36318022fa
Move all static assets into app/assets/
Favicons, icons, and site.webmanifest moved from app/ root to app/assets/.
Updated all href references in index.html accordingly.

https://claude.ai/code/session_01MkM7p8Us3L8YAfLKGA13NS
2026-06-08 13:23:37 +00:00

1863 lines
84 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

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

<!DOCTYPE html>
<html lang="en" dir="ltr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="format-detection" content="telephone=no">
<title>Invoice</title>
<link rel="icon" href="assets/favicon.svg" type="image/svg+xml">
<link rel="icon" href="assets/favicon-48.png" sizes="48x48" type="image/png">
<link rel="icon" href="assets/favicon-32.png" sizes="32x32" type="image/png">
<link rel="icon" href="assets/favicon-16.png" sizes="16x16" type="image/png">
<link rel="apple-touch-icon" href="assets/apple-touch-icon.png">
<link rel="manifest" href="assets/site.webmanifest">
<meta name="theme-color" content="#2f6fed">
<script>
/* Restore persisted theme before first paint */
(function(){var t=localStorage.getItem("kb-theme");if(t)document.documentElement.setAttribute("data-theme",t);})();
</script>
<style>
/* ── Fonts (Bunny CDN — privacy-respecting, offline-graceful) ──────────── */
@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");
}
/* ── kBenestad design tokens — light ───────────────────────────────────── */
:root {
--font-sans: "Schibsted Grotesk", -apple-system, BlinkMacSystemFont, "Segoe UI", 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 — auto (OS) or forced via [data-theme="dark"] ───────────── */
@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;
}
/* ── Reset / base ───────────────────────────────────────────────────────── */
*, *::before, *::after { 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;
}
input, select, textarea, button { font-family: inherit; }
.kb-mono { font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
/* ── Page shell ─────────────────────────────────────────────────────────── */
.kb-wrap { max-width: 960px; margin: 0 auto; padding: 22px 20px 56px; }
/* ── Toolbar ────────────────────────────────────────────────────────────── */
.kb-toolbar {
display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
margin-bottom: 18px;
}
.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: 5px 10px; border-radius: 4px; cursor: pointer;
}
.kb-seg button.is-active { background: var(--accent-soft); color: var(--accent); }
.kb-seg button:hover:not(.is-active):not(:disabled) { color: var(--text); }
.kb-seg button:disabled { opacity: .4; cursor: not-allowed; }
.kb-iconbtn {
display: inline-grid; place-items: center; width: 32px; height: 32px;
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: center;
gap: 16px; padding-bottom: 14px; margin-bottom: 20px;
border-bottom: 1px solid var(--border);
}
.app-wordmark {
display: inline-flex; align-items: center; gap: 8px;
font: 700 var(--fs-h1)/1 var(--font-sans);
color: var(--text); letter-spacing: -0.01em; user-select: none;
}
.app-wordmark svg { flex-shrink: 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); }
/* ── Cards ──────────────────────────────────────────────────────────────── */
.kb-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow-sm);
padding: 18px 20px;
margin-bottom: 14px;
}
.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 14px;
}
.kb-card__title::before {
content: ""; display: block; width: 3px; height: 14px;
border-radius: 2px; background: var(--accent); flex-shrink: 0;
}
.kb-card--flex { display: flex; flex-direction: column; }
.kb-card--flex .kb-totals { margin-top: auto; }
/* ── Form grids ─────────────────────────────────────────────────────────── */
.kb-grid { display: grid; gap: 12px 14px; }
.kb-grid.cols-2 { grid-template-columns: 1fr 1fr; }
.kb-grid.cols-3 { grid-template-columns: 1fr 1fr 1fr; }
.kb-field { display: flex; flex-direction: column; gap: 5px; min-width: 0; }
.kb-label {
font-size: var(--fs-label); font-weight: 600; letter-spacing: 0.03em;
text-transform: uppercase; color: var(--text-muted); display: block;
}
/* ── Inputs / selects ───────────────────────────────────────────────────── */
.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: 8px 10px; 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 {
font-family: var(--font-mono); text-align: right; font-variant-numeric: tabular-nums;
}
.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 8px center;
padding-right: 28px;
}
/* ── 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: 9px 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--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--lg { padding: 13px 26px; }
.kb-btn--sm { padding: 5px 10px; font-size: var(--fs-small); }
.kb-btn--block { width: 100%; }
/* round / buttons for rows */
.kb-row .kb-circbtn { margin-top: 6px; }
.kb-circbtn {
width: 24px; height: 24px; border-radius: 50%;
display: inline-grid; place-items: center;
font-size: 15px; line-height: 1; font-weight: 700;
cursor: pointer; padding: 0; flex-shrink: 0;
background: var(--surface); border: 1px solid var(--accent); color: var(--accent);
}
.kb-circbtn:hover { background: var(--accent); color: var(--on-accent); }
.kb-circbtn--rm { border-color: var(--danger); color: var(--danger); }
.kb-circbtn--rm:hover { background: var(--danger); color: #fff; }
/* ── Row grids (line items / tax rows) ──────────────────────────────────── */
.kb-rowhead, .kb-row { display: grid; align-items: start; gap: 8px; }
.kb-rowhead { align-items: center; }
.kb-rowhead {
padding: 0 4px 8px;
font-size: var(--fs-label); font-weight: 700;
text-transform: uppercase; letter-spacing: .04em; color: var(--text-muted);
}
.kb-rowhead .r { text-align: right; }
.kb-row {
padding: 6px 4px; border-radius: var(--radius-sm);
border-left: 3px solid transparent;
}
.kb-row:hover { background: var(--surface-2); }
.kb-row .r { text-align: right; font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
/* Invoice-specific column grids */
.inv-lines-head { grid-template-columns: 78px 88px 1fr 108px 108px 34px; }
.inv-line { grid-template-columns: 78px 88px 1fr 108px 108px 34px; }
.inv-tax-head { grid-template-columns: 1fr 100px 34px; }
.inv-tax { grid-template-columns: 1fr 100px 34px; }
/* ── Totals panel ───────────────────────────────────────────────────────── */
.kb-totals { width: 100%; }
.kb-totals .row {
display: flex; justify-content: space-between; gap: 16px;
padding: 6px 0; font-size: var(--fs-base);
}
.kb-totals .row .lab { color: var(--text-muted); }
.kb-totals .row .val { font-family: var(--font-mono); font-variant-numeric: tabular-nums; color: var(--text); }
.kb-totals .grand {
margin-top: 8px; padding-top: 12px; border-top: 1px solid var(--border-strong);
display: flex; justify-content: space-between; align-items: baseline; gap: 16px;
}
.kb-totals .grand .lab { font-weight: 700; color: var(--text); }
.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;
}
/* ── FX expansion block ─────────────────────────────────────────────────── */
.fx-block {
background: var(--surface-2); border: 1px solid var(--border);
border-radius: var(--radius-sm); padding: 12px 14px; margin: 4px 0 6px;
}
.fx-note { font-size: var(--fs-small); color: var(--text-muted); margin-top: 8px; }
.fx-note strong { color: var(--text); font-family: var(--font-mono); }
/* ── Charge-to locked state ─────────────────────────────────────────────── */
#ct-fields.locked .kb-input,
#ct-fields.locked .kb-select {
background: var(--surface-3) !important;
color: var(--text-muted) !important;
cursor: not-allowed;
pointer-events: none;
}
/* ── Payment section ────────────────────────────────────────────────────── */
.pay-terms-row { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; flex-wrap: wrap; }
.pay-by-row { display: flex; align-items: center; gap: 8px; }
#paybydisp { font-family: var(--font-mono); font-weight: 600; font-variant-numeric: tabular-nums; }
/* ── Loading / error ────────────────────────────────────────────────────── */
#loading { padding: 48px; text-align: center; color: var(--text-muted); }
.error-box {
background: var(--danger-soft); border: 1px solid var(--danger-border);
color: var(--danger); padding: 16px 20px; border-radius: var(--radius);
font-size: var(--fs-small); line-height: 1.7; margin: 20px 0;
}
.error-box code { font-family: var(--font-mono); font-size: 12px; }
/* ── Footer ─────────────────────────────────────────────────────────────── */
.kb-footer {
max-width: 960px; margin: 0 auto; padding: 16px 20px 12px;
font-size: var(--fs-small); color: var(--text-muted);
display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
border-top: 1px solid var(--border);
}
.kb-footer a { color: var(--text-muted); text-decoration: none; }
.kb-footer a:hover { color: var(--accent); text-decoration: underline; }
.kb-footer .sep { opacity: .4; }
/* ── Responsive ─────────────────────────────────────────────────────────── */
@media (max-width: 680px) {
.kb-grid.cols-2, .kb-grid.cols-3 { grid-template-columns: 1fr; }
.kb-doctitle { text-align: left; }
.inv-lines-head { display: none; }
.inv-line { grid-template-columns: 1fr 1fr; gap: 6px 8px; }
.inv-tax { grid-template-columns: 1fr 80px 34px; }
}
</style>
</head>
<body>
<div class="kb-wrap">
<!-- Utility toolbar -->
<div class="kb-toolbar" id="lang-bar">
<div id="lang-part" style="display:none">
<select id="lang-sel" class="kb-select" style="width:auto;padding:5px 28px 5px 8px;font-size:var(--fs-small)"></select>
</div>
<div class="spacer"></div>
<div class="kb-seg" role="group" aria-label="Text size">
<button id="sz-down" onclick="bumpZoom(-1)" aria-label="Smaller text">A</button>
<button id="sz-up" onclick="bumpZoom(1)" aria-label="Larger text">A+</button>
</div>
<span id="sz-label" style="font-size:12px;color:var(--text-muted);font-family:var(--font-mono)">100%</span>
<button class="kb-iconbtn" id="btn-theme" onclick="toggleTheme()" title="Toggle light/dark" aria-label="Toggle dark mode">
<svg viewBox="0 0 16 16" width="15" height="15" fill="currentColor">
<path d="M8 1.5a6.5 6.5 0 1 0 0 13A6.5 6.5 0 0 0 8 1.5zM0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8z"/>
<path d="M8 1.5V14.5A6.5 6.5 0 0 1 8 1.5z"/>
</svg>
</button>
</div>
<!-- Document header -->
<header class="kb-header">
<div class="app-wordmark">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="28" height="28" role="img" aria-label="Invoice">
<rect width="48" height="48" rx="12" fill="var(--accent)"/>
<path d="M14 9 H29 L34 14 V39 H14 Z" fill="none" stroke="#fff" stroke-width="3" stroke-linejoin="round"/>
<path d="M29 9 V14 H34" fill="none" stroke="#fff" stroke-width="3" stroke-linejoin="round"/>
<path d="M18.5 20 H27" stroke="#fff" stroke-width="2.6" stroke-linecap="round"/>
<path d="M18.5 25 H29.5" stroke="#fff" stroke-width="2.6" stroke-linecap="round" opacity=".55"/>
<path d="M18.5 33 H29.5" stroke="#fff" stroke-width="3.2" stroke-linecap="round"/>
</svg>
invoice
</div>
<div class="kb-doctitle">
<h1 id="inv-title">Invoice</h1>
</div>
</header>
<!-- Loading / error placeholder -->
<div id="loading">Loading configuration&#8230;</div>
<!-- Main form (injected by JS after config loads) -->
<div id="form-root"></div>
</div><!-- /.kb-wrap -->
<!-- About modal -->
<div id="about-modal" style="display:none;position:fixed;inset:0;z-index:999;
background:rgba(0,0,0,.45);align-items:center;justify-content:center;padding:20px">
<div style="background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);
box-shadow:var(--shadow);max-width:520px;width:100%;padding:28px 28px 20px">
<h2 id="about-dialog-header" style="font-size:18px;font-weight:700;margin:0 0 16px;color:var(--text)"></h2>
<div id="about-dialog-body" style="font-size:var(--fs-base);color:var(--text-soft);line-height:1.65;
max-height:60vh;overflow-y:auto"></div>
<div style="margin-top:20px;text-align:right">
<button id="about-btn-close" class="kb-btn kb-btn--ghost" onclick="closeAbout()">Close</button>
</div>
</div>
</div>
<footer id="app-footer" class="kb-footer"></footer>
<!-- js-yaml -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/js-yaml/4.1.0/js-yaml.min.js"
crossorigin="anonymous"></script>
<!-- jsPDF -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js"
crossorigin="anonymous"></script>
<!-- marked (About modal markdown) -->
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"
crossorigin="anonymous"></script>
<script>
"use strict";
// ── State ─────────────────────────────────────────────────────────────────────
let cfg = null;
let lang = "en";
let lid = 0;
const lines = {};
let tlid = 0;
const tLines = {};
let _loading = false;
// ── i18n ──────────────────────────────────────────────────────────────────────
function t(key) {
const e = cfg?.translations?.[key];
if (!e) return key;
return e[lang] ?? e[cfg["default-code"]] ?? key;
}
// ── Numbers ───────────────────────────────────────────────────────────────────
function fmt(n) {
if (n === "" || n == null || isNaN(+n)) return "0.00";
return (+n).toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
function pn(s) { return parseFloat(String(s ?? 0).replace(/,/g, "")) || 0; }
// ── Date ─────────────────────────────────────────────────────────────────────
const MONTHS_FULL = ["January","February","March","April","May","June",
"July","August","September","October","November","December"];
const MONTHS_SHORT = ["Jan","Feb","Mar","Apr","May","Jun",
"Jul","Aug","Sep","Oct","Nov","Dec"];
function fmtDate(v) {
if (!v) return "";
const [yr, mo, dy] = v.split("-").map(Number);
const pattern = cfg?.["date-format"] || "d MMMM YYYY";
return pattern.replace(/YYYY|YY|MMMM|MMM|MM|M|dd|d/g, tok => {
switch (tok) {
case "YYYY": return yr;
case "YY": return String(yr).slice(-2);
case "MMMM": return MONTHS_FULL[mo - 1];
case "MMM": return MONTHS_SHORT[mo - 1];
case "MM": return String(mo).padStart(2, "0");
case "M": return mo;
case "dd": return String(dy).padStart(2, "0");
case "d": return dy;
default: return tok;
}
});
}
// ── HTML escape ───────────────────────────────────────────────────────────────
function h(s) {
if (s == null) return "";
return String(s).replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;");
}
// ── Countries ─────────────────────────────────────────────────────────────────
const COUNTRIES = [
["","— Select country —"],
["AF","Afghanistan"],["AX","Åland Islands"],["AL","Albania"],["DZ","Algeria"],
["AS","American Samoa"],["AD","Andorra"],["AO","Angola"],["AI","Anguilla"],
["AQ","Antarctica"],["AG","Antigua and Barbuda"],["AR","Argentina"],
["AM","Armenia"],["AW","Aruba"],["AU","Australia"],["AT","Austria"],
["AZ","Azerbaijan"],["BS","Bahamas"],["BH","Bahrain"],["BD","Bangladesh"],
["BB","Barbados"],["BY","Belarus"],["BE","Belgium"],["BZ","Belize"],
["BJ","Benin"],["BM","Bermuda"],["BT","Bhutan"],["BO","Bolivia"],
["BQ","Bonaire, Sint Eustatius and Saba"],["BA","Bosnia and Herzegovina"],
["BW","Botswana"],["BV","Bouvet Island"],["BR","Brazil"],
["IO","British Indian Ocean Territory"],["BN","Brunei"],["BG","Bulgaria"],
["BF","Burkina Faso"],["BI","Burundi"],["CV","Cabo Verde"],["KH","Cambodia"],
["CM","Cameroon"],["CA","Canada"],["KY","Cayman Islands"],
["CF","Central African Republic"],["TD","Chad"],["CL","Chile"],["CN","China"],
["CX","Christmas Island"],["CC","Cocos (Keeling) Islands"],["CO","Colombia"],
["KM","Comoros"],["CG","Congo"],["CD","Congo, Democratic Republic"],
["CK","Cook Islands"],["CR","Costa Rica"],["CI","Côte d'Ivoire"],
["HR","Croatia"],["CU","Cuba"],["CW","Curaçao"],["CY","Cyprus"],
["CZ","Czech Republic"],["DK","Denmark"],["DJ","Djibouti"],["DM","Dominica"],
["DO","Dominican Republic"],["EC","Ecuador"],["EG","Egypt"],
["SV","El Salvador"],["GQ","Equatorial Guinea"],["ER","Eritrea"],
["EE","Estonia"],["SZ","Eswatini"],["ET","Ethiopia"],
["FK","Falkland Islands"],["FO","Faroe Islands"],["FJ","Fiji"],
["FI","Finland"],["FR","France"],["GF","French Guiana"],
["PF","French Polynesia"],["TF","French Southern Territories"],["GA","Gabon"],
["GM","Gambia"],["GE","Georgia"],["DE","Germany"],["GH","Ghana"],
["GI","Gibraltar"],["GR","Greece"],["GL","Greenland"],["GD","Grenada"],
["GP","Guadeloupe"],["GU","Guam"],["GT","Guatemala"],["GG","Guernsey"],
["GN","Guinea"],["GW","Guinea-Bissau"],["GY","Guyana"],["HT","Haiti"],
["HM","Heard Island and McDonald Islands"],["VA","Holy See"],["HN","Honduras"],
["HK","Hong Kong"],["HU","Hungary"],["IS","Iceland"],["IN","India"],
["ID","Indonesia"],["IR","Iran"],["IQ","Iraq"],["IE","Ireland"],
["IM","Isle of Man"],["IL","Israel"],["IT","Italy"],["JM","Jamaica"],
["JP","Japan"],["JE","Jersey"],["JO","Jordan"],["KZ","Kazakhstan"],
["KE","Kenya"],["KI","Kiribati"],["KP","Korea, North"],["KR","Korea, South"],
["KW","Kuwait"],["KG","Kyrgyzstan"],["LA","Laos"],["LV","Latvia"],
["LB","Lebanon"],["LS","Lesotho"],["LR","Liberia"],["LY","Libya"],
["LI","Liechtenstein"],["LT","Lithuania"],["LU","Luxembourg"],["MO","Macao"],
["MG","Madagascar"],["MW","Malawi"],["MY","Malaysia"],["MV","Maldives"],
["ML","Mali"],["MT","Malta"],["MH","Marshall Islands"],["MQ","Martinique"],
["MR","Mauritania"],["MU","Mauritius"],["YT","Mayotte"],["MX","Mexico"],
["FM","Micronesia"],["MD","Moldova"],["MC","Monaco"],["MN","Mongolia"],
["ME","Montenegro"],["MS","Montserrat"],["MA","Morocco"],["MZ","Mozambique"],
["MM","Myanmar"],["NA","Namibia"],["NR","Nauru"],["NP","Nepal"],
["NL","Netherlands"],["NC","New Caledonia"],["NZ","New Zealand"],
["NI","Nicaragua"],["NE","Niger"],["NG","Nigeria"],["NU","Niue"],
["NF","Norfolk Island"],["MK","North Macedonia"],
["MP","Northern Mariana Islands"],["NO","Norway"],["OM","Oman"],
["PK","Pakistan"],["PW","Palau"],["PS","Palestine"],["PA","Panama"],
["PG","Papua New Guinea"],["PY","Paraguay"],["PE","Peru"],["PH","Philippines"],
["PN","Pitcairn"],["PL","Poland"],["PT","Portugal"],["PR","Puerto Rico"],
["QA","Qatar"],["RE","Réunion"],["RO","Romania"],["RU","Russia"],
["RW","Rwanda"],["BL","Saint Barthélemy"],["SH","Saint Helena"],
["KN","Saint Kitts and Nevis"],["LC","Saint Lucia"],
["MF","Saint Martin (French)"],["PM","Saint Pierre and Miquelon"],
["VC","Saint Vincent and the Grenadines"],["WS","Samoa"],["SM","San Marino"],
["ST","Sao Tome and Principe"],["SA","Saudi Arabia"],["SN","Senegal"],
["RS","Serbia"],["SC","Seychelles"],["SL","Sierra Leone"],["SG","Singapore"],
["SX","Sint Maarten"],["SK","Slovakia"],["SI","Slovenia"],
["SB","Solomon Islands"],["SO","Somalia"],["ZA","South Africa"],
["GS","South Georgia and South Sandwich Islands"],["SS","South Sudan"],
["ES","Spain"],["LK","Sri Lanka"],["SD","Sudan"],["SR","Suriname"],
["SJ","Svalbard and Jan Mayen"],["SE","Sweden"],["CH","Switzerland"],
["SY","Syria"],["TW","Taiwan"],["TJ","Tajikistan"],["TZ","Tanzania"],
["TH","Thailand"],["TL","Timor-Leste"],["TG","Togo"],["TK","Tokelau"],
["TO","Tonga"],["TT","Trinidad and Tobago"],["TN","Tunisia"],["TR","Turkey"],
["TM","Turkmenistan"],["TC","Turks and Caicos Islands"],["TV","Tuvalu"],
["UG","Uganda"],["UA","Ukraine"],["AE","United Arab Emirates"],
["GB","United Kingdom"],["UM","United States Minor Outlying Islands"],
["US","United States"],["UY","Uruguay"],["UZ","Uzbekistan"],["VU","Vanuatu"],
["VE","Venezuela"],["VN","Vietnam"],["VG","Virgin Islands, British"],
["VI","Virgin Islands, U.S."],["WF","Wallis and Futuna"],
["EH","Western Sahara"],["YE","Yemen"],["ZM","Zambia"],["ZW","Zimbabwe"]
];
const COUNTRY_MAP = Object.fromEntries(COUNTRIES.slice(1));
function countryOpts(sel) {
return COUNTRIES.map(([c, n]) =>
`<option value="${h(c)}" ${c === sel ? "selected" : ""}>${h(n)}</option>`
).join("");
}
// ── Theme toggle ──────────────────────────────────────────────────────────────
function toggleTheme() {
const cur = document.documentElement.getAttribute("data-theme");
const isDark = cur === "dark" ||
(!cur && window.matchMedia("(prefers-color-scheme: dark)").matches);
const next = isDark ? "light" : "dark";
document.documentElement.setAttribute("data-theme", next);
localStorage.setItem("kb-theme", next);
}
// ── Load config ───────────────────────────────────────────────────────────────
async function loadCfg() {
try {
const r = await fetch("config.yml");
if (!r.ok) throw new Error(`HTTP ${r.status}`);
cfg = jsyaml.load(await r.text());
lang = cfg["default-code"] || "en";
boot();
} catch (err) {
document.getElementById("loading").innerHTML =
`<div class="error-box"><strong>Could not load config.yml:</strong> ${h(err.message)}<br>
Serve the <code>app/</code> folder from a web server (e.g. <code>npx serve .</code>).</div>`;
}
}
// ── Boot ──────────────────────────────────────────────────────────────────────
function boot() {
buildLangBar();
buildForm();
buildFooter();
restoreStorage();
if (!loadLines()) addLine();
document.getElementById("loading").style.display = "none";
}
// ── Font-size accessibility ────────────────────────────────────────────────────
const ZOOMS = [0.8, 0.85, 0.9, 0.95, 1.0, 1.1, 1.2, 1.3];
const ZOOM_LABELS = ["80%", "85%", "90%", "95%", "100%", "110%", "120%", "130%"];
let zoomIdx = +(localStorage.getItem("zoomIdx") ?? 4);
function applyZoom() {
const fr = document.getElementById("form-root");
if (fr) fr.style.zoom = ZOOMS[zoomIdx];
const lbl = document.getElementById("sz-label");
if (lbl) lbl.textContent = ZOOM_LABELS[zoomIdx];
document.getElementById("sz-down").disabled = zoomIdx === 0;
document.getElementById("sz-up").disabled = zoomIdx === ZOOMS.length - 1;
}
function bumpZoom(dir) {
zoomIdx = Math.max(0, Math.min(ZOOMS.length - 1, zoomIdx + dir));
localStorage.setItem("zoomIdx", zoomIdx);
applyZoom();
}
// ── Language bar ──────────────────────────────────────────────────────────────
function buildLangBar() {
applyZoom();
const langs = cfg.languages || [{ code: cfg["default-code"], name: cfg["default-name"] }];
if (langs.length < 2) return;
const part = document.getElementById("lang-part");
if (part) part.style.display = "";
const sel = document.getElementById("lang-sel");
sel.innerHTML = langs.map(l =>
`<option value="${h(l.code)}" ${l.code === lang ? "selected" : ""}>${h(l.name)}</option>`
).join("");
sel.addEventListener("change", e => {
lang = e.target.value;
const ld = langs.find(l => l.code === lang);
document.documentElement.lang = lang;
document.documentElement.dir = ld?.direction || "ltr";
relabel();
});
}
// ── Build form ────────────────────────────────────────────────────────────────
function buildForm() {
document.getElementById("inv-title").textContent = t("invoice");
const today = new Date();
const dateDef = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}-${String(today.getDate()).padStart(2, "0")}`;
const curOpts = (cfg.currencies || []).map((c, i) =>
`<option value="${h(c)}" ${i === 0 ? "selected" : ""}>${h(c)}</option>`).join("");
const pcOpts = (cfg["project-codes"] || []).map(pc => `<option value="${h(pc)}">${h(pc)}</option>`).join("");
const ctOpts = (cfg["charge-to"] || []).map((ct, i) => `<option value="${i}">${h(ct.display || ct.name)}</option>`).join("");
document.getElementById("form-root").innerHTML = `
<form id="the-form" novalidate>
<!-- Row 1: Sender + Invoice details -->
<div class="kb-grid cols-2" style="margin-bottom:14px;align-items:start">
<section class="kb-card" style="margin:0">
<h2 class="kb-card__title" id="sec-sender">${t("sender-section")}</h2>
<div class="kb-grid">
<div class="kb-field">
<label class="kb-label" id="lbl-sn" for="sn">${t("sender-name")}</label>
<input id="sn" type="text" class="kb-input" data-ls="sn" autocomplete="name">
</div>
<div class="kb-field">
<label class="kb-label" id="lbl-sa1" for="sa1">${t("sender-address1")}</label>
<input id="sa1" type="text" class="kb-input" data-ls="sa1" autocomplete="address-line1">
</div>
<div class="kb-field">
<label class="kb-label" id="lbl-sa2" for="sa2">${t("sender-address2")}</label>
<input id="sa2" type="text" class="kb-input" data-ls="sa2" autocomplete="address-line2">
</div>
<div class="kb-field">
<label class="kb-label" id="lbl-sa3" for="sa3">${t("sender-address3")}</label>
<input id="sa3" type="text" class="kb-input" data-ls="sa3">
</div>
<div class="kb-field">
<label class="kb-label" id="lbl-sa4" for="sa4">${t("sender-address4")}</label>
<input id="sa4" type="text" class="kb-input" data-ls="sa4">
</div>
<div class="kb-field">
<label class="kb-label" id="lbl-sc" for="sc">${t("sender-country")}</label>
<select id="sc" class="kb-select" data-ls="sc">${countryOpts("")}</select>
</div>
<div class="kb-grid cols-2">
<div class="kb-field">
<label class="kb-label" id="lbl-sp" for="sp">${t("sender-phone")}</label>
<input id="sp" type="tel" class="kb-input" data-ls="sp" autocomplete="tel">
</div>
<div class="kb-field">
<label class="kb-label" id="lbl-se" for="se">${t("sender-email")}</label>
<input id="se" type="email" class="kb-input" data-ls="se" autocomplete="email">
</div>
</div>
<div class="kb-field">
<label class="kb-label" id="lbl-stax" for="stax">${t("vat-id")}</label>
<input id="stax" type="text" class="kb-input" data-ls="stax">
</div>
</div>
</section>
<div>
<section class="kb-card">
<h2 class="kb-card__title" id="sec-invdet">${t("invoice-details-section")}</h2>
<div class="kb-grid cols-2">
<div class="kb-field">
<label class="kb-label" id="lbl-idate" for="idate">${t("invoice-date")}</label>
<input id="idate" type="date" class="kb-input" value="${dateDef}">
</div>
<div class="kb-field">
<label class="kb-label" id="lbl-ino" for="ino">${t("invoice-no")}</label>
<input id="ino" type="text" class="kb-input" data-ls="ino">
</div>
<div class="kb-field">
<label class="kb-label" id="lbl-icur" for="icur">${t("invoice-currency")}</label>
<select id="icur" class="kb-select" data-ls="icur">${curOpts}</select>
</div>
<div class="kb-field">
<label class="kb-label" id="lbl-pcode" for="pcode">${t("project-code")}</label>
<select id="pcode" class="kb-select" data-ls="pcode">
<option value="">${t("select")}</option>
${pcOpts}
<option value="__other__">${t("other")}</option>
</select>
</div>
</div>
<div class="kb-field" id="pcode-other-wrap" style="display:none;margin-top:12px">
<label class="kb-label" id="lbl-pcode-other" for="pcode-other">${t("project-code")} (${t("other")})</label>
<input id="pcode-other" type="text" class="kb-input" data-ls="pcode-other">
</div>
</section>
<section class="kb-card" style="margin-bottom:0">
<h2 class="kb-card__title" id="lbl-pay-sec">${t("payment")}</h2>
<div class="pay-terms-row">
<label class="kb-label" id="lbl-pterm">${t("payment-terms")}</label>
<input id="pterm" type="number" min="0" value="7" class="kb-input" style="width:64px"
oninput="calcPayBy()" data-ls="pterm">
<span id="lbl-days" style="font-size:var(--fs-small);color:var(--text-muted)">${t("payment-days")}</span>
</div>
<div class="pay-by-row">
<span style="font-size:var(--fs-small);color:var(--text-muted)" id="lbl-paybyl">${t("pay-by")}:</span>
<span id="paybydisp"></span>
</div>
<div id="bank-section" style="display:none;margin-top:14px">
<div class="kb-grid">
<div class="kb-field">
<label class="kb-label" id="lbl-pacct" for="pacct">${t("account-holder")}</label>
<input id="pacct" type="text" class="kb-input" data-ls="pacct">
</div>
<div class="kb-grid cols-2">
<div class="kb-field">
<label class="kb-label" id="lbl-piban" for="piban">${t("account-no")}</label>
<input id="piban" type="text" class="kb-input kb-mono" data-ls="piban">
</div>
<div class="kb-field">
<label class="kb-label" id="lbl-pbic" for="pbic">${t("bank-bic")}</label>
<input id="pbic" type="text" class="kb-input kb-mono" data-ls="pbic">
</div>
</div>
<div class="kb-field">
<label class="kb-label" id="lbl-pbadr">${t("bank-address")}</label>
<input id="pbadr1" type="text" class="kb-input" data-ls="pbadr1">
<input id="pbadr2" type="text" class="kb-input" style="margin-top:6px" data-ls="pbadr2">
</div>
<div class="kb-field">
<label class="kb-label" id="lbl-pref" for="pref">${t("payment-ref")}</label>
<input id="pref" type="text" class="kb-input" data-ls="pref">
</div>
</div>
</div>
</section>
</div>
</div>
<!-- Row 2: Bill-to -->
<section class="kb-card">
<h2 class="kb-card__title">
<span id="sec-ct">${t("charge-to")}</span>
<select id="ct-pick" class="kb-select" data-ls="ct-pick"
style="width:auto;font-size:var(--fs-small);margin-left:8px;padding:4px 28px 4px 8px">
<option value="">${t("select")}</option>
${ctOpts}
<option value="__other__">${t("other")}</option>
</select>
</h2>
<div id="ct-fields" class="locked">
<div class="kb-grid cols-2">
<div class="kb-field">
<label class="kb-label" id="lbl-ctn" for="ctn">${t("charge-to-name")}</label>
<input id="ctn" type="text" class="kb-input">
</div>
<div class="kb-field">
<label class="kb-label" id="lbl-ca1" for="ca1">${t("charge-to-address1")}</label>
<input id="ca1" type="text" class="kb-input">
</div>
<div class="kb-field">
<label class="kb-label" id="lbl-ca2" for="ca2">${t("charge-to-address2")}</label>
<input id="ca2" type="text" class="kb-input">
</div>
<div class="kb-field">
<label class="kb-label" id="lbl-ca3" for="ca3">${t("charge-to-address3")}</label>
<input id="ca3" type="text" class="kb-input">
</div>
<div class="kb-field">
<label class="kb-label" id="lbl-ca4" for="ca4">${t("charge-to-address4")}</label>
<input id="ca4" type="text" class="kb-input">
</div>
<div class="kb-field">
<label class="kb-label" id="lbl-cc" for="cc">${t("charge-to-country")}</label>
<select id="cc" class="kb-select">${countryOpts("")}</select>
</div>
<div class="kb-field">
<label class="kb-label" id="lbl-cph" for="cph">${t("charge-to-phone")}</label>
<input id="cph" type="tel" class="kb-input">
</div>
<div class="kb-field">
<label class="kb-label" id="lbl-cem" for="cem">${t("charge-to-email")}</label>
<input id="cem" type="email" class="kb-input">
</div>
<div class="kb-field">
<label class="kb-label" id="lbl-cvat" for="cvat">${t("vat-id")}</label>
<input id="cvat" type="text" class="kb-input">
</div>
<div class="kb-field">
<label class="kb-label" id="lbl-creg" for="creg">${t("registration-no")}</label>
<input id="creg" type="text" class="kb-input">
</div>
</div>
</div>
</section>
<!-- Row 3: Line items -->
<section class="kb-card">
<h2 class="kb-card__title">
<span id="sec-lines">${t("invoice-lines")}</span>
<button type="button" id="btn-reset-lines" class="kb-btn kb-btn--ghost kb-btn--sm"
style="margin-left:auto" onclick="resetLines()">${t("reset-lines")}</button>
</h2>
<div class="kb-rowhead kb-row inv-lines-head">
<span id="th-qty">${t("qty")}</span>
<span id="th-uom">${t("uom")}</span>
<span id="th-desc">${t("description")}</span>
<span class="r" id="th-price">${t("price")}</span>
<span class="r" id="th-tot">${t("line-total")}</span>
<span></span>
</div>
<div id="tbody"></div>
</section>
<!-- Row 4: Tax + Summary -->
<div class="kb-grid cols-2" style="align-items:start;margin-bottom:14px">
<section class="kb-card" style="margin:0">
<h2 class="kb-card__title">Tax</h2>
<div class="kb-rowhead kb-row inv-tax-head">
<span>Rate &amp; type</span>
<span class="r">Amount</span>
<span></span>
</div>
<div id="tax-tbody"></div>
<div style="margin-top:10px">
<button type="button" class="kb-btn kb-btn--dashed" id="btn-add-tax"
onclick="addTaxLine()">${t("add-tax")}</button>
</div>
</section>
<section class="kb-card kb-card--flex" style="margin:0">
<h2 class="kb-card__title">Summary</h2>
<div class="kb-totals">
<div id="tot-pre">
<div class="row">
<span class="lab" id="lbl-sub">${t("subtotal")}</span>
<span class="val" id="v-sub">0.00</span>
</div>
</div>
<div id="tot-post">
<div class="row">
<span class="lab" id="lbl-paid">${t("paid")}</span>
<span class="val">
<input id="paid-inp" type="number" value="0" min="0" step="0.01"
class="kb-input num" style="width:110px;padding:5px 8px">
</span>
</div>
<div class="grand">
<span class="lab" id="lbl-topay">${t("to-pay")}</span>
<span class="val" id="v-topay">0.00</span>
</div>
</div>
</div>
</section>
</div>
<!-- Generate -->
<button type="submit" id="btn-generate" class="kb-btn kb-btn--primary kb-btn--lg kb-btn--block">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round" width="16" height="16">
<path d="M8 2v9m0 0 4-4M8 11 4 7"/><line x1="2" y1="14" x2="14" y2="14"/>
</svg>
${t("generate-invoice")}
</button>
</form>`;
document.getElementById("pcode").addEventListener("change", function () {
document.getElementById("pcode-other-wrap").style.display = this.value === "__other__" ? "block" : "none";
});
document.getElementById("ct-pick").addEventListener("change", e => fillChargeTo(e.target.value));
document.getElementById("idate").addEventListener("change", calcPayBy);
document.getElementById("paid-inp").addEventListener("input", calcTotals);
document.getElementById("the-form").addEventListener("submit", e => { e.preventDefault(); generateInvoice(); });
document.querySelectorAll("[data-ls]").forEach(el => el.addEventListener("change", saveStorage));
document.getElementById("icur").addEventListener("change", () => {
Object.keys(lines).forEach(k => {
if (document.getElementById(`frate-lbl-${k}`)) updateFxLabels(+k);
});
});
calcPayBy();
}
// ── Project-code dropdown ─────────────────────────────────────────────────────
function updateProjectCodes(codes) {
const arr = (codes && codes.length) ? codes : (cfg["project-codes"] || []);
const sel = document.getElementById("pcode");
if (!sel) return;
const prev = sel.value;
sel.innerHTML = `<option value="">${t("select")}</option>`
+ arr.map(pc => `<option value="${h(pc)}">${h(pc)}</option>`).join("")
+ `<option value="__other__">${t("other")}</option>`;
sel.value = arr.includes(prev) ? prev : "";
const wrap = document.getElementById("pcode-other-wrap");
if (wrap) wrap.style.display = sel.value === "__other__" ? "block" : "none";
}
// ── Fill charge-to ────────────────────────────────────────────────────────────
function fillChargeTo(v) {
const f = (id, val) => { const el = document.getElementById(id); if (el) el.value = val ?? ""; };
const fields = document.getElementById("ct-fields");
const bsec = document.getElementById("bank-section");
if (bsec) bsec.style.display = v === "__other__" ? "" : "none";
if (v === "") {
["ctn","ca1","ca2","ca3","ca4","cc","cph","cem","cvat","creg"].forEach(id => f(id, ""));
fields?.classList.add("locked");
updateProjectCodes(null);
return;
}
if (v === "__other__") {
fields?.classList.remove("locked");
updateProjectCodes(null);
return;
}
const ct = (cfg["charge-to"] || [])[+v];
if (!ct) return;
f("ctn", ct.name); f("ca1", ct.address1);
f("ca2", ct.address2); f("ca3", ct.address3);
f("ca4", ct.address4); f("cc", ct.country);
f("cph", ct.phone); f("cem", ct.email);
f("cvat", ct["vat-id"]); f("creg", ct["reg-no"]);
fields?.classList.add("locked");
updateProjectCodes(ct["project-codes"] || null);
if (ct.currency) {
const icurEl = document.getElementById("icur");
if (icurEl) { icurEl.value = ct.currency; saveStorage(); }
}
}
// ── Select option helpers ─────────────────────────────────────────────────────
function uomOpts(sel) {
let o = `<option value="">${t("select")}</option>`;
(cfg.uom || []).forEach(u => {
const lbl = u.labels?.[lang] ?? u.labels?.[cfg["default-code"]] ?? u.code;
o += `<option value="${h(u.code)}" ${u.code === sel ? "selected" : ""}>${h(lbl)} (${h(u.code)})</option>`;
});
return o;
}
function prodOpts(sel) {
let o = `<option value="">${t("select")}</option>`;
(cfg.products || []).forEach((p, i) => {
const desc = p.description?.[lang] ?? p.description?.[cfg["default-code"]] ?? p.code;
o += `<option value="${i}" ${String(i) === String(sel) ? "selected" : ""}>${h(desc)}</option>`;
});
o += `<option value="__other__" ${"__other__" === sel ? "selected" : ""}>${t("other")}</option>`;
return o;
}
function currOpts(sel) {
return (cfg.currencies || ["USD","EUR","GBP"]).map(c =>
`<option value="${c}" ${c === sel ? "selected" : ""}>${c}</option>`
).join("");
}
function getTaxTypeOpts(sel) {
return (cfg["tax-types"] || []).map(tt => {
const lbl = tt.labels?.[lang] ?? tt.labels?.[cfg["default-code"]] ?? tt.key;
return `<option value="${h(tt.key)}" ${tt.key === sel ? "selected" : ""}>${h(lbl)}</option>`;
}).join("");
}
function addTaxLine() {
const i = tlid++;
tLines[i] = {};
const tbody = document.getElementById("tax-tbody");
const defaultKey = (cfg["tax-types"]||[])[0]?.key || "";
const div = document.createElement("div");
div.className = "kb-row inv-tax"; div.id = `tlr-${i}`;
div.innerHTML = `
<div style="display:flex;align-items:center;gap:5px;flex-wrap:wrap">
<input type="number" id="tv-${i}" class="kb-input num" value="" placeholder="0"
min="0" step="any" oninput="calcTotals()" style="width:60px">
<select id="tt-${i}" class="kb-select" style="width:auto" onchange="calcTotals()">
<option value="pct">${t("tax-pct")}</option>
<option value="amt">${t("tax-amount")}</option>
</select>
<select id="tk-${i}" class="kb-select" style="width:auto" onchange="calcTotals()">
${getTaxTypeOpts(defaultKey)}
</select>
</div>
<span class="r kb-mono" id="ta-${i}">0.00</span>
<button type="button" class="kb-circbtn kb-circbtn--rm" onclick="removeTaxLine(${i})"
aria-label="Remove">&#x2212;</button>`;
tbody.appendChild(div);
calcTotals();
}
function removeTaxLine(i) {
document.getElementById(`tlr-${i}`)?.remove();
delete tLines[i];
calcTotals();
}
// ── Add / remove line ─────────────────────────────────────────────────────────
function addLine() {
const i = lid++;
lines[i] = {};
const tbody = document.getElementById("tbody");
const alRow = document.getElementById("al-row");
if (alRow) alRow.remove();
const div = document.createElement("div");
div.className = "kb-row inv-line"; div.id = `lr-${i}`;
div.innerHTML = `
<input type="number" id="qty-${i}" class="kb-input num" value="" min="0" step="0.01"
placeholder="0.00" oninput="calcLine(${i});saveLines()" disabled>
<select id="uom-${i}" class="kb-select" onchange="calcLine(${i});saveLines()" disabled>
${uomOpts("")}
</select>
<div>
<select id="dsel-${i}" class="kb-select" onchange="pickProduct(${i})">
${prodOpts("")}
</select>
<input type="text" id="dtxt-${i}" class="kb-input" placeholder="${h(t("description"))}"
style="margin-top:5px;display:none" oninput="calcLine(${i});saveLines()">
<div style="display:flex;align-items:center;gap:6px;margin-top:6px">
<label style="font-size:var(--fs-label);font-weight:600;letter-spacing:.03em;text-transform:uppercase;color:var(--text-muted);white-space:nowrap"
id="lbl-fx-${i}">${t("foreign-currency")}:</label>
<select id="fx-${i}" class="kb-select" style="width:auto" onchange="toggleFx(${i})">
<option value="no">${t("no-option")}</option>
<option value="yes">${t("yes")}</option>
</select>
</div>
</div>
<input type="number" id="price-${i}" class="kb-input num" value="" min="0" step="any"
oninput="calcLine(${i});saveLines()" disabled>
<span class="r kb-mono" id="ltv-${i}"
style="font-size:var(--fs-base);font-weight:600">0.00</span>
<button type="button" class="kb-circbtn kb-circbtn--rm" onclick="removeLine(${i})"
aria-label="Remove">&#x2212;</button>`;
tbody.appendChild(div);
const alNew = document.createElement("div");
alNew.id = "al-row";
alNew.style.cssText = "padding:10px 4px 4px";
alNew.innerHTML = `<button type="button" class="kb-btn kb-btn--dashed" id="btn-al"
onclick="addLine()">${t("add-line")}</button>`;
tbody.appendChild(alNew);
calcTotals();
return i;
}
function removeLine(i) {
document.getElementById(`lr-${i}`)?.remove();
document.getElementById(`fx-row-${i}`)?.remove();
delete lines[i];
calcTotals();
saveLines();
}
function pickProduct(i) {
const v = document.getElementById(`dsel-${i}`)?.value;
const txt = document.getElementById(`dtxt-${i}`);
const qtyEl = document.getElementById(`qty-${i}`);
const uomEl = document.getElementById(`uom-${i}`);
const priceEl= document.getElementById(`price-${i}`);
const fxOn = document.getElementById(`fx-${i}`)?.value === "yes";
txt.style.display = v === "__other__" ? "block" : "none";
if (v === "") {
if (qtyEl) { qtyEl.disabled = true; qtyEl.value = ""; }
if (uomEl) { uomEl.disabled = true; uomEl.value = ""; }
if (priceEl) { priceEl.disabled = true; priceEl.value = ""; }
} else if (v === "__other__") {
if (qtyEl) qtyEl.disabled = false;
if (uomEl) uomEl.disabled = false;
if (!fxOn && priceEl) priceEl.disabled = false;
} else {
const p = (cfg.products || [])[+v];
if (p) {
if (uomEl && p.uom) uomEl.value = p.uom;
if (priceEl && p.price != null) priceEl.value = p.price;
if (qtyEl && p.qty != null) qtyEl.value = p.qty;
}
if (qtyEl) qtyEl.disabled = false;
if (uomEl) uomEl.disabled = true;
if (!fxOn && priceEl) priceEl.disabled = false;
}
calcLine(i);
saveLines();
}
// ── Foreign currency ──────────────────────────────────────────────────────────
function toggleFx(i) {
const on = document.getElementById(`fx-${i}`)?.value === "yes";
const lr = document.getElementById(`lr-${i}`);
const priceEl = document.getElementById(`price-${i}`);
document.getElementById(`fx-row-${i}`)?.remove();
if (!on) {
lr?.classList.remove("open");
const dv = document.getElementById(`dsel-${i}`)?.value;
if (dv && dv !== "" && priceEl) priceEl.disabled = false;
calcTotals();
saveLines();
return;
}
if (priceEl) { priceEl.disabled = true; priceEl.value = ""; }
lr?.classList.add("open");
const lcy = document.getElementById("icur")?.value || "";
const defaultFcy = (cfg.currencies || ["USD"])[0] || "USD";
const fxDiv = document.createElement("div");
fxDiv.className = "fx-block"; fxDiv.id = `fx-row-${i}`;
fxDiv.innerHTML = `
<div class="kb-grid cols-3">
<div class="kb-field">
<span class="kb-label" id="fcur-lbl-${i}">${t("currency-code")}</span>
<select id="fcur-${i}" class="kb-select"
onchange="updateFxLabels(${i});calcLine(${i});saveLines()">
${currOpts(defaultFcy)}
</select>
</div>
<div class="kb-field">
<span class="kb-label" id="frate-lbl-${i}">${t("exchange-rate")}</span>
<div style="display:flex;align-items:center;gap:6px;flex-wrap:wrap">
<input type="number" id="frate-${i}" class="kb-input num" value="" min="0" step="any"
style="flex:1;min-width:70px" oninput="calcFxFromPer(${i})">
<select id="rcur-${i}" class="kb-select" style="width:auto"
onchange="updateFxLabels(${i});calcFxFromPer(${i});saveLines()">
<option value="${h(lcy)}">${h(lcy)}</option>
<option value="${h(defaultFcy)}">${h(defaultFcy)}</option>
</select>
<span class="kb-label" style="margin:0">per</span>
<span id="rother-${i}" style="font-size:var(--fs-small);font-weight:600;color:var(--text)">${h(defaultFcy)}</span>
</div>
</div>
<div class="kb-field">
<span class="kb-label" id="fper-lbl-${i}">${t("per-item")} ${h(defaultFcy)}</span>
<input type="number" id="fper-${i}" class="kb-input num" value="" min="0" step="any"
oninput="calcFxFromPer(${i})">
</div>
</div>
<div class="fx-note"><span id="ftot-lbl-${i}">${t("total-foreign")} ${h(defaultFcy)}</span>: <strong id="fltot-${i}">0.00</strong></div>`;
lr?.insertAdjacentElement("afterend", fxDiv);
calcLine(i);
saveLines();
}
function calcFxFromPer(i) {
const per = pn(document.getElementById(`fper-${i}`)?.value);
const rate = pn(document.getElementById(`frate-${i}`)?.value) || 1;
const rcur = document.getElementById(`rcur-${i}`)?.value;
const lcy = document.getElementById("icur")?.value || "";
const prEl = document.getElementById(`price-${i}`);
if (prEl) prEl.value = rcur === lcy
? (per * rate).toFixed(6)
: (per / rate).toFixed(6);
calcLine(i);
saveLines();
}
// ── Calculations ──────────────────────────────────────────────────────────────
function calcLine(i) {
const qty = pn(document.getElementById(`qty-${i}`)?.value);
const price = pn(document.getElementById(`price-${i}`)?.value);
const el = document.getElementById(`ltv-${i}`);
if (el) el.textContent = fmt(qty * price);
if (document.getElementById(`fx-${i}`)?.value === "yes") {
const per = pn(document.getElementById(`fper-${i}`)?.value);
const ltot = document.getElementById(`fltot-${i}`);
if (ltot) ltot.textContent = fmt(per * qty);
}
calcTotals();
}
function calcPayBy() {
const idate = document.getElementById("idate")?.value;
const terms = parseInt(document.getElementById("pterm")?.value) || 0;
const el = document.getElementById("paybydisp");
if (!el) return;
if (idate && terms > 0) {
const d = new Date(idate + "T00:00:00");
d.setDate(d.getDate() + terms);
const iso = `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,"0")}-${String(d.getDate()).padStart(2,"0")}`;
el.textContent = fmtDate(iso);
} else {
el.textContent = "";
}
}
function calcTotals() {
let sub = 0;
Object.keys(lines).forEach(i => {
sub += pn(document.getElementById(`qty-${i}`)?.value) *
pn(document.getElementById(`price-${i}`)?.value);
});
let totalTax = 0;
Object.keys(tLines).forEach(i => {
const val = pn(document.getElementById(`tv-${i}`)?.value);
const type = document.getElementById(`tt-${i}`)?.value || "pct";
const amt = type === "pct" ? sub * (val / 100) : val;
totalTax += amt;
const el = document.getElementById(`ta-${i}`);
if (el) el.textContent = fmt(amt);
});
const paid = pn(document.getElementById("paid-inp")?.value);
const toPay = sub + totalTax - paid;
const set = (id, v) => { const e = document.getElementById(id); if (e) e.textContent = fmt(v); };
set("v-sub", sub);
set("v-topay", toPay);
}
// ── LocalStorage ──────────────────────────────────────────────────────────────
const LS_DATA = "inv_data_v1";
const LS_GEN = "inv_generated_v1";
function saveStorage() {
const d = {};
document.querySelectorAll("[data-ls]").forEach(el => { d[el.dataset.ls] = el.value; });
localStorage.setItem(LS_DATA, JSON.stringify(d));
}
function restoreStorage() {
try {
const raw = localStorage.getItem(LS_DATA);
if (!raw) return;
const d = JSON.parse(raw);
const wasGen = localStorage.getItem(LS_GEN) === "true";
Object.entries(d).forEach(([k, v]) => {
const el = document.querySelector(`[data-ls="${k}"]`);
if (el) el.value = v;
});
// Restore charge-to: rebuild its project code options then re-apply pcode.
// fillChargeTo() calls updateProjectCodes() which resets the pcode select,
// and may call saveStorage() mid-flow, so we re-apply and re-save after.
const ctPickEl = document.getElementById("ct-pick");
if (ctPickEl?.value) {
fillChargeTo(ctPickEl.value);
const pcodeEl = document.getElementById("pcode");
if (pcodeEl && d.pcode) pcodeEl.value = d.pcode;
saveStorage();
}
if (d.pcode === "__other__") {
const w = document.getElementById("pcode-other-wrap");
if (w) w.style.display = "block";
}
if (wasGen && d.ino) {
const bumped = bumpNum(d.ino);
const el = document.getElementById("ino");
if (el) el.value = bumped;
d.ino = bumped;
localStorage.setItem(LS_DATA, JSON.stringify(d));
localStorage.setItem(LS_GEN, "false");
}
} catch (_) {}
}
function bumpNum(s) {
return String(s).replace(/(\d+)$/, m => String(+m + 1).padStart(m.length, "0"));
}
// ── Relabel on language switch ────────────────────────────────────────────────
function relabel() {
const lm = {
"inv-title":"invoice","lbl-language":"language",
"sec-sender":"sender-section","sec-invdet":"invoice-details-section",
"sec-ct":"charge-to","sec-lines":"invoice-lines",
"lbl-sn":"sender-name","lbl-sa1":"sender-address1","lbl-sa2":"sender-address2",
"lbl-sa3":"sender-address3","lbl-sa4":"sender-address4","lbl-sc":"sender-country",
"lbl-sp":"sender-phone","lbl-se":"sender-email","lbl-stax":"vat-id",
"lbl-idate":"invoice-date","lbl-icur":"invoice-currency",
"lbl-pcode":"project-code","lbl-ino":"invoice-no",
"lbl-ctn":"charge-to-name","lbl-ca1":"charge-to-address1","lbl-ca2":"charge-to-address2",
"lbl-ca3":"charge-to-address3","lbl-ca4":"charge-to-address4","lbl-cc":"charge-to-country",
"lbl-cph":"charge-to-phone","lbl-cem":"charge-to-email",
"lbl-cvat":"vat-id","lbl-creg":"registration-no",
"lbl-pay-sec":"payment","lbl-pterm":"payment-terms",
"lbl-days":"payment-days","lbl-paybyl":"pay-by",
"lbl-pacct":"account-holder","lbl-piban":"account-no","lbl-pbic":"bank-bic",
"lbl-pbadr":"bank-address","lbl-pref":"payment-ref",
"th-qty":"qty","th-uom":"uom","th-desc":"description",
"th-price":"price","th-tot":"line-total",
"lbl-sub":"subtotal","lbl-paid":"paid","lbl-topay":"to-pay",
};
Object.entries(lm).forEach(([id, key]) => {
const el = document.getElementById(id);
if (el) el.textContent = t(key);
});
Object.keys(lines).forEach(i => {
const fl = document.getElementById(`lbl-fx-${i}`);
if (fl) fl.textContent = t("foreign-currency") + ":";
const dt = document.getElementById(`dtxt-${i}`);
if (dt) dt.placeholder = t("description");
const ue = document.getElementById(`uom-${i}`);
if (ue) { const s = ue.value; ue.innerHTML = uomOpts(s); }
const de = document.getElementById(`dsel-${i}`);
if (de) { const s = de.value; de.innerHTML = prodOpts(s); }
const fe = document.getElementById(`fx-${i}`);
if (fe) {
const s = fe.value;
fe.innerHTML = `<option value="no">${t("no-option")}</option><option value="yes">${t("yes")}</option>`;
fe.value = s;
}
if (document.getElementById(`rcur-${i}`)) updateFxLabels(+i);
});
const alBtn = document.getElementById("btn-al");
if (alBtn) alBtn.textContent = t("add-line");
const genBtn = document.getElementById("btn-generate");
if (genBtn) genBtn.textContent = t("generate-invoice");
Object.keys(tLines).forEach(i => {
const ttEl = document.getElementById(`tt-${i}`);
if (ttEl) {
const sv = ttEl.value;
ttEl.innerHTML = `<option value="pct">${t("tax-pct")}</option><option value="amt">${t("tax-amount")}</option>`;
ttEl.value = sv;
}
const tkEl = document.getElementById(`tk-${i}`);
if (tkEl) { const sv = tkEl.value; tkEl.innerHTML = getTaxTypeOpts(sv); }
});
const atBtn = document.getElementById("btn-add-tax");
if (atBtn) atBtn.textContent = t("add-tax");
const rstBtn = document.getElementById("btn-reset-lines");
if (rstBtn) rstBtn.textContent = t("reset-lines");
const ctPick = document.getElementById("ct-pick");
if (ctPick) {
const cur = ctPick.value;
const ctOpts = (cfg["charge-to"] || []).map((ct, i) =>
`<option value="${i}">${h(ct.display || ct.name)}</option>`).join("");
ctPick.innerHTML = `<option value="">${t("select")}</option>${ctOpts}<option value="__other__">${t("other")}</option>`;
ctPick.value = cur;
}
}
// ── Generate invoice ──────────────────────────────────────────────────────────
function generateInvoice() {
saveStorage();
localStorage.setItem(LS_GEN, "true");
buildPDF();
}
// ── Shared: gather invoice data ───────────────────────────────────────────────
function gatherData(renderLang) {
const dl = renderLang || cfg["default-code"] || "en";
const td = key => { const e = cfg?.translations?.[key]; return e?.[dl] ?? e?.["en"] ?? key; };
const g = id => (document.getElementById(id)?.value ?? "").trim();
const sName = g("sn");
const sAddr = [g("sa1"),g("sa2"),g("sa3"),g("sa4")].filter(Boolean);
const sCntry = COUNTRY_MAP[g("sc")] || "";
const sPh = g("sp"), sEm = g("se"), sTax = g("stax");
const iDate = g("idate");
const pCode = g("pcode") === "__other__" ? g("pcode-other") : g("pcode");
const iNo = g("ino");
const iCur = g("icur");
const ctName = g("ctn");
const ctAddr = [g("ca1"),g("ca2"),g("ca3"),g("ca4")].filter(Boolean);
const ctCntry= COUNTRY_MAP[g("cc")] || "";
const ctPh = g("cph"), ctEm = g("cem"), ctVat = g("cvat"), ctReg = g("creg");
const pTerm = parseInt(document.getElementById("pterm")?.value) || 0;
const pPayBy = document.getElementById("paybydisp")?.textContent || "";
const bsecEl = document.getElementById("bank-section");
const bankVis = bsecEl && bsecEl.style.display !== "none";
const pAcct = bankVis ? g("pacct") : "";
const pIban = bankVis ? g("piban") : "";
const pBic = bankVis ? g("pbic") : "";
const pBadr1 = bankVis ? g("pbadr1"): "";
const pBadr2 = bankVis ? g("pbadr2"): "";
const pRef = bankVis ? g("pref") : "";
const hasBank = !!(pAcct || pIban || pBic || pBadr1 || pRef);
const hidePaymentOut = cfg["hide-payment-info"] === true || cfg["hide-payment-info"] === "yes";
let sub = 0;
const rows = [];
Object.keys(lines).sort((a,b)=>+a-+b).forEach(i => {
const qty = pn(document.getElementById(`qty-${i}`)?.value);
const price = pn(document.getElementById(`price-${i}`)?.value);
const tot = qty * price;
if (qty === 0 && price === 0) return;
sub += tot;
const uomCode = document.getElementById(`uom-${i}`)?.value || "";
const uomObj = (cfg.uom||[]).find(u=>u.code===uomCode);
const uomLbl = uomObj?.labels?.[dl] ?? uomObj?.labels?.[cfg["default-code"]] ?? uomCode;
const dv = document.getElementById(`dsel-${i}`)?.value;
let desc = "";
if (dv === "__other__") {
desc = (document.getElementById(`dtxt-${i}`)?.value || "").trim();
} else if (dv !== "" && dv != null) {
const p = (cfg.products||[])[+dv];
desc = p?.description?.[dl] ?? p?.description?.[cfg["default-code"]] ?? "";
}
const isFx = document.getElementById(`fx-${i}`)?.value === "yes";
let fxNote = null;
if (isFx) {
const cur = document.getElementById(`fcur-${i}`)?.value || "";
const rcur = document.getElementById(`rcur-${i}`)?.value || "";
const rate = pn(document.getElementById(`frate-${i}`)?.value) || 1;
const per = pn(document.getElementById(`fper-${i}`)?.value);
const foreignTot = per * qty;
fxNote = { cur, rcur, rate, per, foreignTot, td };
}
rows.push({ qty, uomLbl, desc, price, tot, fxNote });
});
let totalTax = 0;
const taxes = [];
Object.keys(tLines).sort((a,b)=>+a-+b).forEach(i => {
const val = pn(document.getElementById(`tv-${i}`)?.value);
const type = document.getElementById(`tt-${i}`)?.value || "pct";
const key = document.getElementById(`tk-${i}`)?.value || "";
const ttObj = (cfg["tax-types"]||[]).find(x => x.key === key);
const label = ttObj?.labels?.[dl] ?? ttObj?.labels?.[cfg["default-code"]] ?? key;
const amt = type === "pct" ? sub * (val / 100) : val;
totalTax += amt;
const lineLabel = type === "pct" ? `${label} ${val}%` : `${label} (${fmt(val)})`;
taxes.push({ val, type, key, label, lineLabel, amt });
});
const paid = pn(document.getElementById("paid-inp")?.value);
const toPay = sub + totalTax - paid;
return { dl, td, sName, sAddr, sCntry, sPh, sEm, sTax, iDate, pCode, iNo, iCur,
ctName, ctAddr, ctCntry, ctPh, ctEm, ctVat, ctReg,
pTerm, pPayBy, pAcct, pIban, pBic, pBadr1, pBadr2, pRef, hasBank, hidePaymentOut,
rows, sub, taxes, totalTax, paid, toPay };
}
// ── Build PDF with jsPDF + Helvetica ─────────────────────────────────────────
function buildPDF() {
if (!window.jspdf) { alert("PDF library not loaded — check your internet connection."); return; }
const { jsPDF } = window.jspdf;
const paperFmt = (cfg["paper-format"] || "a4").toLowerCase();
const doc = new jsPDF({ unit: "mm", format: paperFmt });
const PW = paperFmt === "letter" ? 215.9 : 210;
const PH = paperFmt === "letter" ? 279.4 : 297;
const ML = 15, MR = 15, MT = 15;
const CW = PW - ML - MR;
const XR = PW - MR;
const fc = (r,g,b) => doc.setFillColor(r,g,b);
const dc = (r,g,b) => doc.setDrawColor(r,g,b);
const tc = (r,g,b) => doc.setTextColor(r,g,b);
const fb = sz => { doc.setFont("helvetica","bold"); doc.setFontSize(sz); };
const fn = sz => { doc.setFont("helvetica","normal"); doc.setFontSize(sz); };
const tL = (s,x,y) => doc.text(String(s??""), x, y);
const tR = (s,x,y) => doc.text(String(s??""), x, y, {align:"right"});
const sp = (s,w) => doc.splitTextToSize(String(s??""), w);
const { dl, td, sName, sAddr, sCntry, sPh, sEm, sTax, iDate, pCode, iNo, iCur,
ctName, ctAddr, ctCntry, ctPh, ctEm, ctVat, ctReg,
pTerm, pPayBy, pAcct, pIban, pBic, pBadr1, pBadr2, pRef, hasBank, hidePaymentOut,
rows, sub, taxes, paid, toPay } = gatherData();
const showBank = hasBank && !hidePaymentOut;
// ── 2×2 HEADER ───────────────────────────────────────────────────────────
const GAP = 8;
const LW = (CW - GAP) / 2;
const XM_L = ML + LW + GAP;
let y = MT;
let ly = y, ry = y;
if (sName) { fb(13); tc(30,45,69); tL(sName, ML, ly); ly += 6; }
fn(8.5); tc(75,85,99);
[...sAddr, sCntry].filter(Boolean).forEach(l => { tL(l, ML, ly); ly += 4.5; });
if (sPh || sEm || sTax) {
const parts = [];
if (sPh) parts.push(`${td("sender-phone")}: ${sPh}`);
if (sEm) parts.push(`${td("sender-email")}: ${sEm}`);
if (sTax) parts.push(`${td("vat-id")}: ${sTax}`);
fn(8); tc(107,114,128);
sp(parts.join(" "), LW).forEach(l => { tL(l, ML, ly); ly += 4; }); ly += 1;
}
fb(24); tc(30,45,69); tR(td("invoice"), XR, ry); ry += 10;
const metaRows = [
iNo ? [td("invoice-no"), iNo] : null,
iDate ? [td("invoice-date"), fmtDate(iDate)] : null,
pCode ? [td("project-code"), pCode] : null,
iCur ? [td("invoice-currency"), iCur] : null,
].filter(Boolean);
metaRows.forEach(([lbl, val]) => {
fn(8.5); tc(107,114,128); tR(lbl + ":", XR - 42, ry);
fb(8.5); tc(17,24,39); tR(val, XR, ry);
ry += 5;
});
const row1Y = Math.max(ly, ry) + 4;
dc(209,213,219); doc.setLineWidth(0.3);
doc.line(ML, row1Y, XR, row1Y);
let ly2 = row1Y + 5, ry2 = row1Y + 5;
fb(7); tc(107,114,128); tL(td("charge-to").toUpperCase(), ML, ly2); ly2 += 5;
if (ctName) {
fb(10); tc(30,45,69); tL(ctName, ML, ly2); ly2 += 5.5;
fn(8.5); tc(17,24,39);
[...ctAddr, ctCntry].filter(Boolean).forEach(l => { tL(l, ML, ly2); ly2 += 4.5; });
const ctParts = [];
if (ctPh) ctParts.push(`${td("charge-to-phone")}: ${ctPh}`);
if (ctEm) ctParts.push(`${td("charge-to-email")}: ${ctEm}`);
if (ctVat) ctParts.push(`${td("vat-id")}: ${ctVat}`);
if (ctReg) ctParts.push(`${td("registration-no")}: ${ctReg}`);
if (ctParts.length) {
fn(8); tc(107,114,128);
sp(ctParts.join(" "), LW).forEach(l => { tL(l, ML, ly2); ly2 += 4; }); ly2 += 1;
}
}
if (pTerm > 0 || showBank) {
fb(7); tc(107,114,128); tL(td("payment").toUpperCase(), XM_L, ry2); ry2 += 5;
if (pTerm > 0) {
const ts = `${td("payment-terms")}: ${pTerm} ${td("payment-days")}${pPayBy ? ` ${td("pay-by")}: ${pPayBy}` : ""}`;
fn(8.5); tc(17,24,39); tL(ts, XM_L, ry2); ry2 += 5;
}
if (showBank) {
const LLBL = 46;
const payRows = [
pAcct ? [td("account-holder"), pAcct] : null,
pIban ? [td("account-no"), pIban] : null,
pBic ? [td("bank-bic"), pBic] : null,
(pBadr1||pBadr2) ? [td("bank-address"), [pBadr1,pBadr2].filter(Boolean).join(", ")] : null,
].filter(Boolean);
payRows.forEach(([lbl, val]) => {
fn(8); tc(107,114,128); tL(lbl + ":", XM_L, ry2);
fn(8.5); tc(17,24,39);
const wrapped = sp(val, LW - LLBL - 2);
wrapped.forEach((line, i) => tL(line, XM_L + LLBL, ry2 + i * 4));
ry2 += Math.max(4.5, wrapped.length * 4);
});
if (pRef) {
fn(8); tc(107,114,128); tL(td("payment-ref") + ":", XM_L, ry2);
fb(8.5); tc(17,24,39); tL(pRef, XM_L + LLBL, ry2); ry2 += 5;
}
}
}
y = Math.max(ly2, ry2) + 5;
dc(30,45,69); doc.setLineWidth(0.6);
doc.line(ML, y, XR, y); y += 6;
// ── LINE ITEMS TABLE ──────────────────────────────────────────────────────
const CQ=18, CU=18, CP=28, CT=34;
const CD = CW - CQ - CU - CP - CT;
const xQ=ML, xU=xQ+CQ, xD=xU+CU, xP=xD+CD, xT=xP+CP;
const TH = 7;
if (y + TH > PH - 40) { doc.addPage(); y = MT; }
fc(30,45,69); dc(255,255,255); doc.setLineWidth(0);
doc.rect(ML, y, CW, TH, "F");
fb(8); tc(255,255,255);
tL(td("qty"), xQ+2, y+4.8);
tL(td("uom"), xU+2, y+4.8);
tL(td("description"), xD+2, y+4.8);
tR(td("price"), xP+CP-2, y+4.8);
tR(td("line-total"), XR-2, y+4.8);
y += TH;
const ROW_H = 7.5;
rows.forEach((row, idx) => {
const dLines = sp(row.desc, CD - 4);
const descH = Math.max(0, (dLines.length - 1) * 3.8);
let fxH = 0;
if (row.fxNote) {
const fxStr = `${td("per-item")}: ${row.fxNote.cur} ${fmt(row.fxNote.per)} `
+ `(${(+row.fxNote.rate).toFixed(5)} ${row.fxNote.cur} = 1 ${iCur})`;
const fxLines = sp(fxStr, CD + CP - 4);
fxH = fxLines.length * 3.5 + 1;
}
const rh = ROW_H + descH + fxH;
if (y + rh > PH - 30) { doc.addPage(); y = MT; }
if (idx % 2 === 1) {
fc(249,250,251); dc(255,255,255); doc.setLineWidth(0);
doc.rect(ML, y, CW, rh, "F");
}
dc(209,213,219); doc.setLineWidth(0.1);
doc.line(ML, y+rh, XR, y+rh);
const yt = y + 5;
const qStr = row.qty % 1 === 0 ? String(row.qty) : fmt(row.qty);
fn(8.5); tc(17,24,39);
tL(qStr, xQ+2, yt);
tL(row.uomLbl, xU+2, yt);
dLines.forEach((dline, li) => tL(dline, xD+2, yt + li*3.8));
fn(8.5); tc(17,24,39); tR(fmt(row.price), xP+CP-2, yt);
fb(8.5); tc(17,24,39); tR(fmt(row.tot), XR-2, yt);
if (row.fxNote) {
const fxStr = `${td("per-item")}: ${row.fxNote.cur} ${fmt(row.fxNote.per)} `
+ `(${(+row.fxNote.rate).toFixed(5)} ${row.fxNote.cur} = 1 ${iCur})`;
fn(7); tc(107,114,128);
const fxLines = sp(fxStr, CD + CP - 4);
fxLines.forEach((fl, fi) => tL(fl, xD+2, y + ROW_H + descH + 3.2 + fi * 3.5));
}
y += rh;
});
y += 4;
// ── TOTALS ────────────────────────────────────────────────────────────────
const TW = 78;
const TX = XR - TW;
const TRH = 6.5;
const TLBX = XR - 40;
const totRows = [
[td("subtotal"), fmt(sub)],
...taxes.map(tx => [tx.lineLabel, fmt(tx.amt)]),
...(paid > 0 ? [[td("paid"), "" + fmt(paid)]] : []),
];
totRows.forEach(([lbl, val]) => {
if (y + TRH > PH - 20) { doc.addPage(); y = MT; }
fn(8.5); tc(107,114,128); tR(lbl, TLBX, y+4.5);
fn(8.5); tc(17,24,39); tR(val, XR-2, y+4.5);
dc(209,213,219); doc.setLineWidth(0.1);
doc.line(TX, y+TRH, XR, y+TRH);
y += TRH;
});
if (y + 9 > PH - 10) { doc.addPage(); y = MT; }
fc(30,45,69); dc(255,255,255); doc.setLineWidth(0);
doc.rect(TX, y, TW, 9, "F");
fn(9); tc(180,195,215); tR(td("to-pay"), TLBX, y+5.8);
fb(11); tc(255,255,255); tR(fmt(toPay), XR-2, y+5.8);
y += 9;
doc.save(iNo ? `invoice-${iNo}.pdf` : "invoice.pdf");
}
// ── Update FX labels when currency or invoice currency changes ────────────────
function updateFxLabels(i) {
const fcy = document.getElementById(`fcur-${i}`)?.value || "FCY";
const lcy = document.getElementById("icur")?.value || "";
const rcurEl = document.getElementById(`rcur-${i}`);
const rotherEl = document.getElementById(`rother-${i}`);
if (rcurEl) {
const prev = rcurEl.value;
rcurEl.innerHTML = `<option value="${h(lcy)}">${h(lcy)}</option><option value="${h(fcy)}">${h(fcy)}</option>`;
rcurEl.value = (prev === lcy || prev === fcy) ? prev : lcy;
}
if (rotherEl) rotherEl.textContent = rcurEl?.value === lcy ? fcy : lcy;
const cl = document.getElementById(`fcur-lbl-${i}`);
if (cl) cl.textContent = t("currency-code");
const rl = document.getElementById(`frate-lbl-${i}`);
if (rl) rl.textContent = t("exchange-rate");
const pl = document.getElementById(`fper-lbl-${i}`);
if (pl) pl.textContent = t("per-item") + " " + fcy;
const tl = document.getElementById(`ftot-lbl-${i}`);
if (tl) tl.textContent = t("total-foreign") + " " + fcy;
}
// ── Save / load invoice lines ──────────────────────────────────────────────────
const LS_LINES = "inv_lines_v1";
function saveLines() {
if (_loading) return;
const data = [];
Object.keys(lines).sort((a, b) => +a - +b).forEach(k => {
const i = +k;
data.push({
dsel: document.getElementById(`dsel-${i}`)?.value || "",
dtxt: document.getElementById(`dtxt-${i}`)?.value || "",
qty: document.getElementById(`qty-${i}`)?.value || "",
uom: document.getElementById(`uom-${i}`)?.value || "",
price: document.getElementById(`price-${i}`)?.value || "",
fx: document.getElementById(`fx-${i}`)?.value || "no",
fcur: document.getElementById(`fcur-${i}`)?.value || "",
rcur: document.getElementById(`rcur-${i}`)?.value || "",
frate: document.getElementById(`frate-${i}`)?.value || "",
fper: document.getElementById(`fper-${i}`)?.value || "",
});
});
localStorage.setItem(LS_LINES, JSON.stringify(data));
}
function loadLines() {
try {
const raw = localStorage.getItem(LS_LINES);
if (!raw) return false;
const data = JSON.parse(raw);
if (!Array.isArray(data) || data.length === 0) return false;
const firstKey = Object.keys(lines)[0];
if (firstKey !== undefined) {
document.getElementById(`lr-${firstKey}`)?.remove();
document.getElementById(`fx-row-${firstKey}`)?.remove();
delete lines[+firstKey];
}
_loading = true;
data.forEach(ld => {
const i = addLine();
if (ld.dsel) {
const dsel = document.getElementById(`dsel-${i}`);
if (dsel) {
dsel.value = ld.dsel;
pickProduct(i);
if (ld.dsel === "__other__" && ld.dtxt) {
const dtxt = document.getElementById(`dtxt-${i}`);
if (dtxt) dtxt.value = ld.dtxt;
}
}
}
if (ld.uom) { const el = document.getElementById(`uom-${i}`); if (el) el.value = ld.uom; }
if (ld.qty) { const el = document.getElementById(`qty-${i}`); if (el) el.value = ld.qty; }
if (ld.fx === "yes") {
const fxEl = document.getElementById(`fx-${i}`);
if (fxEl) {
fxEl.value = "yes";
toggleFx(i);
if (ld.fcur) {
const el = document.getElementById(`fcur-${i}`);
if (el) { el.value = ld.fcur; updateFxLabels(i); }
}
if (ld.rcur) {
const el = document.getElementById(`rcur-${i}`);
if (el) { el.value = ld.rcur; updateFxLabels(i); }
}
if (ld.frate) { const el = document.getElementById(`frate-${i}`); if (el) el.value = ld.frate; }
if (ld.fper) { const el = document.getElementById(`fper-${i}`); if (el) el.value = ld.fper; }
calcFxFromPer(i);
}
} else {
if (ld.price) { const el = document.getElementById(`price-${i}`); if (el) el.value = ld.price; }
calcLine(i);
}
});
_loading = false;
saveLines();
calcTotals();
return true;
} catch (e) {
_loading = false;
return false;
}
}
function resetLines() {
if (!confirm(t("reset-lines") + "?")) return;
Object.keys(lines).forEach(k => {
document.getElementById(`lr-${+k}`)?.remove();
document.getElementById(`fx-row-${+k}`)?.remove();
delete lines[+k];
});
localStorage.removeItem(LS_LINES);
addLine();
calcTotals();
}
// ── Footer & About modal ──────────────────────────────────────────────────────
function aboutData() {
const ab = cfg?.about;
if (!ab) return null;
return ab[lang] ?? ab[cfg["default-code"]] ?? Object.values(ab)[0] ?? null;
}
function buildFooter() {
const footer = document.getElementById("app-footer");
if (!footer) return;
const ab = aboutData();
const aboutLink = ab
? ` <span class="sep">&#183;</span> <a href="#" onclick="openAbout();return false">${h(ab.title ?? "About")}</a>`
: "";
footer.innerHTML =
`<span>&#169; 2026 Kristian Benestad</span>`
+ ` <span class="sep">&#183;</span>`
+ ` <a href="https://docs.benestad.net/invoice" target="_blank" rel="noopener">docs.benestad.net</a>`
+ ` <span class="sep">&#183;</span>`
+ ` <a href="https://github.com/kbenestad/invoice" target="_blank" rel="noopener">kbenestad/invoice</a>`
+ aboutLink;
}
function openAbout() {
const ab = aboutData();
if (!ab) return;
document.getElementById("about-dialog-header").textContent = ab.title ?? "";
document.getElementById("about-dialog-body").innerHTML =
typeof marked !== "undefined" ? marked.parse(ab.content ?? "") : h(ab.content ?? "");
document.getElementById("about-btn-close").textContent = ab["btn-close"] ?? "Close";
document.getElementById("about-modal").style.display = "flex";
document.getElementById("about-btn-close").focus();
}
function closeAbout() {
document.getElementById("about-modal").style.display = "none";
}
document.addEventListener("keydown", e => {
if (e.key === "Escape") closeAbout();
});
// ── Start ─────────────────────────────────────────────────────────────────────
loadCfg();
</script>
</body>
</html>